From 78ed1dd2813dbc1093542ef5cd0c7915eb560640 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Sun, 19 Jul 2026 11:45:08 -0700
Subject: [PATCH] Writing passport: evidence of process instead of an AI score
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
She's submitting work that gets run through an AI detector and wants to
pre-check she won't be wrongly flagged. Petal should not answer that with
a detector of its own: they misfire badly on non-native English (Stanford
2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native
writers), so a percentage aimed at an ESL writer is worse than nothing —
it either scares her off her own voice or gives false comfort.
So the artifact is provenance, not a verdict. Petal already snapshots
every ~3 minutes; this turns that history into a standalone printable
report: session breakdown, word-count growth, span, active time. No score
is emitted anywhere.
Two schema additions back it. preserve_history opts a document out of the
40-snapshot prune cap — right for recovery, wrong for provenance, where
you want the whole span including the oldest rows. content_hash/prev_hash
chain each snapshot to the one before it, so a history edited or thinned
after the fact fails verification. Pruning legitimately severs links, so a
link break reports as "gaps" unless preserve_history is on; only a hash
that fails against its own contents is unconditionally "broken".
The chart's x axis is snapshot order, not wall-clock, and that is the load
-bearing decision. On a linear time axis an essay written in three
sittings across three days renders as three vertical cliffs separated by
empty space — visually identical to text pasted in three chunks, i.e. the
report would have argued the opposite of the truth. Breaks are compressed
into explicitly labelled gutters instead. TestChartGivesWidthToWriting
pins it.
The report volunteers its largest single word-count jump and states its
own limits: it cannot show who was at the keyboard, or whether typed text
was composed or copied in. Overclaiming would be self-defeating — a reader
who catches it overstating discounts all of it.
HTML rather than server-rendered PDF, as with the other exports: a CJK-safe
PDF needs an embedded Unicode font or a headless browser. Print styles are
there so the browser's Save as PDF is the handoff path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
---
internal/db/db.go | 25 ++
internal/db/models.go | 11 +
internal/docs/handlers.go | 13 +-
internal/docs/passport.go | 281 ++++++++++++++
internal/docs/passport_render.go | 387 ++++++++++++++++++++
internal/docs/passport_test.go | 376 +++++++++++++++++++
internal/docs/versions.go | 66 +++-
web/src/api/client.ts | 9 +
web/src/components/History/HistoryPanel.tsx | 66 ++++
9 files changed, 1225 insertions(+), 9 deletions(-)
create mode 100644 internal/docs/passport.go
create mode 100644 internal/docs/passport_render.go
create mode 100644 internal/docs/passport_test.go
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.
+