diff --git a/internal/db/db.go b/internal/db/db.go
index d52dbee..c3a1e5b 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -365,6 +365,31 @@ SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, s
DROP TABLE suggestions;
ALTER TABLE suggestions_new RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
+`,
+ },
+ {
+ // Writing passport: evidence that a document was written, not pasted.
+ //
+ // `preserve_history` opts a document out of auto-snapshot pruning. The
+ // 40-snapshot cap is right for recovery (you want recent states) but
+ // wrong for provenance (you want the *whole* span, oldest included), so
+ // a writer who may need to defend authorship flags the doc and keeps
+ // every snapshot.
+ //
+ // `content_hash`/`prev_hash` chain each snapshot to the one before it:
+ // hash = sha256(prev_hash | doc_id | created_at | word_count | text).
+ // This proves the local history is internally consistent — no snapshot
+ // was edited, reordered, or removed after the fact without breaking
+ // every link downstream. It is NOT third-party attestation: anyone with
+ // the DB and the algorithm could forge a fresh chain. It raises the cost
+ // of a doctored history from "edit one row" to "rebuild all of them".
+ // Pre-existing snapshots keep empty hashes and are reported as
+ // unverifiable rather than as failures.
+ name: "0009_writing_passport",
+ stmt: `
+ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
+ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
`,
},
}
diff --git a/internal/db/models.go b/internal/db/models.go
index 635d7cb..63cd3b1 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -25,6 +25,10 @@ type Document struct {
WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+
+ // PreserveHistory opts this document out of auto-snapshot pruning so its
+ // full writing trail survives as authorship evidence (see the passport).
+ PreserveHistory bool `json:"preserve_history"`
}
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
@@ -42,6 +46,13 @@ type DocumentVersion struct {
WordCount int `json:"word_count"`
Kind string `json:"kind"` // auto | manual | pre_restore
CreatedAt time.Time `json:"created_at"`
+
+ // ContentHash chains this snapshot to the previous one (PrevHash), so a
+ // history that was edited or thinned after the fact fails verification.
+ // Both are empty for snapshots taken before the chain existed. Omitted from
+ // list responses; the passport loads them explicitly.
+ ContentHash string `json:"content_hash,omitempty"`
+ PrevHash string `json:"prev_hash,omitempty"`
}
// Document version kinds, mirrored from the schema CHECK constraint.
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index a946460..b528837 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -139,6 +139,10 @@ type updateRequest struct {
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
+
+ // PreserveHistory toggles the passport's keep-everything mode. Sent alone
+ // by the History panel's toggle, never by the auto-save path.
+ PreserveHistory *bool `json:"preserve_history"`
}
// update applies the provided fields to a document and returns the saved row.
@@ -159,9 +163,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
+ preserve_history = COALESCE(?, preserve_history),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
- req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
+ req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
+ req.PreserveHistory, id, db.LocalUserID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -211,13 +217,14 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
func (h *Handler) fetch(id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
- `SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
+ `SELECT id, user_id, title, content, content_text, tone, word_count,
+ created_at, updated_at, preserve_history
FROM documents
WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
- &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
+ &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
)
return doc, err
}
diff --git a/internal/docs/passport.go b/internal/docs/passport.go
new file mode 100644
index 0000000..8490a23
--- /dev/null
+++ b/internal/docs/passport.go
@@ -0,0 +1,281 @@
+package docs
+
+// Writing passport: a standalone, printable report showing *how* a document was
+// written — when each snapshot landed, how the word count grew, how the work
+// broke into sessions. It exists because automated "AI detector" verdicts are
+// unreliable and skew against non-native English writers, so the useful thing to
+// hand someone who doubts your authorship is not a score but a record.
+//
+// The report is deliberately modest about what it proves (see passportLimits):
+// it evidences a plausible writing process, it does not certify one.
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/db"
+ "gitea.parodia.dev/drwily/petal/internal/httputil"
+)
+
+// Passport tuning.
+const (
+ // sessionGap is the idle time that separates one writing session from the
+ // next. Auto-snapshots fire at most every 3 minutes while typing, so any
+ // gap far above that means the writer stepped away. 45 minutes keeps a
+ // coffee break inside one session but splits morning from evening work.
+ sessionGap = 45 * time.Minute
+
+ // jumpNoteThreshold is the share of the final word count a single
+ // snapshot-to-snapshot increase must exceed before the report calls it out.
+ // A large jump is the first thing a skeptical reader will ask about, so the
+ // report raises it rather than leaving it to be discovered.
+ jumpNoteThreshold = 0.25
+)
+
+// chainHash links a snapshot to its predecessor. Covering prev_hash makes each
+// hash depend on the entire history before it, so altering any earlier snapshot
+// invalidates every later one; covering created_at means a row cannot be
+// silently backdated.
+//
+// This detects tampering with the local database. It is not third-party
+// attestation — someone with the database and this function could regenerate a
+// consistent chain from scratch.
+func chainHash(prevHash, docID string, createdAt time.Time, wordCount int, text string) string {
+ h := sha256.New()
+ fmt.Fprintf(h, "%s\x00%s\x00%d\x00%d\x00%s",
+ prevHash, docID, createdAt.UTC().UnixNano(), wordCount, text)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// --- report model -----------------------------------------------------------
+
+// passportSession is one continuous stretch of work — snapshots with no
+// sessionGap-sized pause between them.
+type passportSession struct {
+ Start, End time.Time
+ Snapshots int
+ WordsAdded int // net change across the session; negative when trimming
+}
+
+// Duration is the observed length of the session: first snapshot to last. A
+// single-snapshot session reports zero, which is why total active time is
+// described as a lower bound.
+func (s passportSession) Duration() time.Duration { return s.End.Sub(s.Start) }
+
+// chain verification outcomes, in the order the report prefers to report them.
+const (
+ chainVerified = "verified" // every hash recomputes and every link holds
+ chainGaps = "gaps" // hashes valid, links broken — consistent with pruning
+ chainPartial = "partial" // some snapshots predate the hash chain
+ chainUnverifiable = "unverifiable" // no snapshot carries a hash
+ chainBroken = "broken" // a hash does not match its own contents
+)
+
+// passportData is everything the template renders.
+type passportData struct {
+ Doc db.Document
+ Versions []db.DocumentVersion // ascending by time
+
+ Sessions []passportSession
+ FirstAt time.Time
+ LastAt time.Time
+ Span time.Duration // wall-clock first snapshot → last
+ ActiveTime time.Duration // summed session durations; a lower bound
+
+ LargestJump int // biggest single snapshot-to-snapshot word increase
+ LargestJumpAt time.Time
+ LargestJumpIdx int // index into Versions, so the chart can mark it
+ NoteJump bool // jump is large enough to be worth pre-empting
+
+ ChainStatus string
+ UnhashedCount int
+ GeneratedAt time.Time
+}
+
+// buildPassport derives the report from a document and its snapshots, which must
+// be ordered oldest-first. It assumes nothing about snapshot spacing.
+func buildPassport(doc db.Document, versions []db.DocumentVersion) passportData {
+ d := passportData{
+ Doc: doc,
+ Versions: versions,
+ GeneratedAt: time.Now(),
+ }
+ if len(versions) == 0 {
+ d.ChainStatus = chainUnverifiable
+ return d
+ }
+
+ d.FirstAt = versions[0].CreatedAt
+ d.LastAt = versions[len(versions)-1].CreatedAt
+ d.Span = d.LastAt.Sub(d.FirstAt)
+
+ cur := passportSession{Start: versions[0].CreatedAt, End: versions[0].CreatedAt, Snapshots: 1}
+
+ // Baseline for the running session's net-words figure. Later sessions
+ // measure from the *previous* session's final count, not from their own
+ // first snapshot, because that first snapshot already contains the few
+ // minutes of typing that preceded it — measuring from it would drop that
+ // work. The first session is the exception: it measures from its own first
+ // snapshot rather than from zero, so a history whose early snapshots were
+ // pruned understates session one instead of reporting the words it never
+ // saw as a sudden addition.
+ startWords := versions[0].WordCount
+
+ for i := 1; i < len(versions); i++ {
+ v, prev := versions[i], versions[i-1]
+
+ if delta := v.WordCount - prev.WordCount; delta > d.LargestJump {
+ d.LargestJump, d.LargestJumpAt, d.LargestJumpIdx = delta, v.CreatedAt, i
+ }
+
+ if v.CreatedAt.Sub(prev.CreatedAt) > sessionGap {
+ cur.WordsAdded = prev.WordCount - startWords
+ d.Sessions = append(d.Sessions, cur)
+ cur = passportSession{Start: v.CreatedAt, End: v.CreatedAt, Snapshots: 1}
+ startWords = prev.WordCount
+ continue
+ }
+ cur.End = v.CreatedAt
+ cur.Snapshots++
+ }
+ cur.WordsAdded = versions[len(versions)-1].WordCount - startWords
+ d.Sessions = append(d.Sessions, cur)
+
+ for _, s := range d.Sessions {
+ d.ActiveTime += s.Duration()
+ }
+
+ final := versions[len(versions)-1].WordCount
+ d.NoteJump = final > 0 && float64(d.LargestJump)/float64(final) > jumpNoteThreshold
+
+ d.ChainStatus, d.UnhashedCount = verifyChain(doc, versions)
+ return d
+}
+
+// verifyChain recomputes every snapshot's hash and checks that each links to the
+// one before it. Returns the outcome and how many snapshots predate the chain.
+//
+// Broken *links* are not evidence of tampering on their own: auto-snapshot
+// pruning legitimately removes rows from the middle of the history, which severs
+// the links across the hole. So a link break is reported as a gap unless the
+// document is in preserve-history mode, where nothing should ever be removed. A
+// hash that fails to match its *own* contents is unambiguous, and always broken.
+func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string, unhashed int) {
+ var (
+ hashed int
+ linkBreak bool
+ prevHash string
+ havePrev bool
+ )
+
+ for _, v := range versions {
+ if v.ContentHash == "" {
+ unhashed++
+ havePrev = false // can't vouch for what follows an unhashed row
+ continue
+ }
+ hashed++
+
+ want := chainHash(v.PrevHash, v.DocID, v.CreatedAt, v.WordCount, v.ContentText)
+ if want != v.ContentHash {
+ return chainBroken, unhashed
+ }
+ if havePrev && v.PrevHash != prevHash {
+ linkBreak = true
+ }
+ prevHash, havePrev = v.ContentHash, true
+ }
+
+ switch {
+ case hashed == 0:
+ return chainUnverifiable, unhashed
+ case linkBreak && doc.PreserveHistory:
+ // Nothing should have been removed from a preserved history.
+ return chainBroken, unhashed
+ case linkBreak:
+ return chainGaps, unhashed
+ case unhashed > 0:
+ return chainPartial, unhashed
+ default:
+ return chainVerified, unhashed
+ }
+}
+
+// --- HTTP -------------------------------------------------------------------
+
+// passport renders the report for one document as a standalone HTML download.
+// HTML rather than PDF for the same reason as the other exports: a CJK-safe PDF
+// needs an embedded Unicode font or a headless browser. The page is styled for
+// printing, so "Save as PDF" in the browser produces the handoff artifact.
+func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
+ docID := chi.URLParam(r, "id")
+
+ doc, err := h.fetch(docID)
+ if errors.Is(err, sql.ErrNoRows) {
+ notFound(w)
+ return
+ }
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+
+ versions, err := h.passportVersions(docID)
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+
+ body := renderPassport(buildPassport(doc, versions))
+
+ filename := sanitizeFilename(doc.Title)
+ if filename == "" {
+ filename = "untitled"
+ }
+ filename += " - writing passport.html"
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Content-Disposition",
+ fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ _, _ = w.Write(body)
+}
+
+// passportVersions loads every snapshot oldest-first with the fields the report
+// and the chain check need — including content_text, which the list endpoint
+// omits as too heavy but verification cannot do without.
+func (h *Handler) passportVersions(docID string) ([]db.DocumentVersion, error) {
+ rows, err := h.DB.Query(
+ `SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind,
+ v.created_at, v.content_hash, v.prev_hash
+ FROM document_versions v
+ JOIN documents d ON d.id = v.doc_id
+ WHERE v.doc_id = ? AND d.user_id = ?
+ ORDER BY v.created_at ASC, v.rowid ASC`,
+ docID, db.LocalUserID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []db.DocumentVersion
+ for rows.Next() {
+ var v db.DocumentVersion
+ if err := rows.Scan(
+ &v.ID, &v.DocID, &v.Title, &v.ContentText, &v.WordCount, &v.Kind,
+ &v.CreatedAt, &v.ContentHash, &v.PrevHash,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/docs/passport_render.go b/internal/docs/passport_render.go
new file mode 100644
index 0000000..501d0b6
--- /dev/null
+++ b/internal/docs/passport_render.go
@@ -0,0 +1,387 @@
+package docs
+
+// HTML rendering for the writing passport. Self-contained (no external assets)
+// and styled for print, so the browser's "Save as PDF" turns it into the file a
+// writer actually hands over.
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "time"
+)
+
+// Chart geometry. The plot is wide and short on purpose: the report's question
+// is "what shape did this document grow in", and a wide aspect makes a steady
+// climb read as steady rather than dramatic.
+const (
+ chartW, chartH = 760, 260
+ padL, padR, padT, padB = 52, 20, 18, 34
+ plotW, plotH = chartW - padL - padR, chartH - padT - padB
+ minBandW = 2.0 // so a single-snapshot session still shows
+
+ // gutterSlots is the space between sessions, in snapshot-slot widths. Wide
+ // enough to read as a break and to seat its duration label.
+ gutterSlots = 2.5
+)
+
+// Palette — the export stylesheet's tokens, reused so a passport looks like it
+// came from the same application as the document it describes.
+const (
+ rose = "#b04a6a"
+ roseLight = "#f6d6e0"
+ roseWash = "#fdeef3"
+ surface = "#fffafb"
+)
+
+func renderPassport(d passportData) []byte {
+ var b strings.Builder
+
+ fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title))
+
+ fmt.Fprintf(&b, ` Writing passport Generated %s%s
+
This document has no saved history yet, so there is +nothing to report. History builds up automatically as you write.
+