docs: define committed DOCX citation and access contract

This commit is contained in:
Rocko committed 2026-10-22 18:23:37 -05:00
1 parent e36bce3526
commit 15e91538f2
1 file changed
+440
@@ -0,0 +1,440 @@
# Q195 O2 W4: committed DOCX citations and access
Design for Gitea #1799, assignment sage-3060/mos-1360. Owner Rocko;
independent reviewer Megavolt. Normative parent: `e36bce352`,
`docs/plans/2026-10-21_q195-writing-editor.md`, blob
`322ec50f2669cffc0f8256d6beffdaccc4d8dba8`, D12, D24-D26, W4 and W4b.
This is a proposed implementation contract, not access enablement or a parser
security PASS. Reviewer owns the separate verdict file; the author does not
write an approval there.
## 1. Gates and compatibility
W4b implements this contract only after independent W4 APPROVE and Sage's
accepted implementation brief, S0 and its other parent gates. A release must
ship extractor, resolver, authorization and sharing together; no partial
agent read rollout. Missing server readiness refuses every O2 read, resolve,
share and flag. A human setting, pin or request field cannot assert readiness.
W5 owns flags, not chapter writes. No agent execute, edit, replace, save,
tracked-change or comment-writing operation is introduced.
Q36 / Vikunja 562 is OPEN. Production Markdown adoption remains closed under
mos-1358, independently of whether this design is approved. If Jason approves
superseded notices, a separate C2 reader/reply row must implement and pass
them before adoption; otherwise Mos records Jason's disposition before any
adoption brief proceeds. This document neither implements nor decides notices.
Existing C2 Markdown, its line-cite schema, library grants and supporting-doc
writer remain unchanged. An authorized old Markdown original is historical
source, never the current DOCX or a permission to its later edits. No DOCX
paragraph is called a line. Following an adopted source from a C2 read,
indexing it as Markdown, changing C2 replies or a synchronized Markdown
projection requires a separately approved Case 2 amendment through Mos/Jason.
W4b must test old C2 fixtures unchanged alongside the new discriminated DOCX
contract. It may migrate only new O2 records, with a named schema edge Sage
allocates; it must not reinterpret existing line citations in place.
## 2. Trust and document identity
The host reader alone opens `<dataRoot>/writing/<business>/<project>/`.
Business, project and document are opaque IDs resolved through the host's
owned manifest, never request paths. Each document has a stable ID and one
canonical DOCX path; a rename does not change its identity. A copy/new import
gets a new document ID and no inherited grant, share, pin or cache authority.
Origin metadata is evidence only. Copying a source does not launder access.
A read snapshots one reachable committed manifest and its DOCX blob from the
same commit. It never combines HEAD's manifest with another commit's DOCX.
The reader accepts only server-resolved commits reachable from that document's
published history; arbitrary git objects and another document's same blob
are not selectable. Historical access also requires the document's current
live authorization. Removed history, missing blobs or missing extractor
revisions refuse; there is no worktree, export or engine-memory fallback.
The commit/blob field is a locator, not an authority token.
The writer's successful commit publishes atomically before Saved. A latest
lookup begun after that receipt resolves that published commit, not an old
cache entry. A lookup already in progress remains internally on its captured
commit. Return the captured version explicitly; never label it latest after
observing a newer publish. Recheck live grants immediately before returning
content, not just before asynchronous extraction.
## 3. Exact wire schema, version 1
All objects reject unknown keys. IDs are nonempty ASCII `[A-Za-z0-9_-]{1,128}`;
commits/blobs are lowercase 40-hex Git object IDs in the repository's current
SHA-1 object format. A future object format requires a new schema revision.
Digest values are lowercase 64-hex SHA-256. Integers are safe nonnegative
JSON integers. No floats, coercion, duplicate JSON keys or implicit defaults
except the reading view described below. These are proposed O2-only records.
A locator is exactly:
```json
{
"kind": "docx-range", "version": 1,
"business": "acme", "project": "book", "document": "chapter_1",
"commit": "<40 lowercase hex>", "blob": "<40 lowercase hex>",
"extractor": {"id": "mosaic-docx-text-v1", "revision": "<64 lowercase hex>"},
"part": {"class": "body", "name": "word/document.xml", "story": "main"},
"paragraph": {"ordinal": 0, "xmlId": null},
"start": 0, "end": 5, "textView": "final", "quote": "Hello",
"quoteDigest": "<sha256 of UTF-8 quote>"
}
```
Part triples are exact: body/main uses the body part; footnote/endnote/comment
uses its class-matching part and a canonical nonnegative decimal story ID
(`0` or `[1-9][0-9]*`), never a user path. Paragraph `xmlId` is JSON null
when absent. A textView is always recorded in every locator; there is no
resolver default. quote is a nonempty valid Unicode string bounded to 16,384
code points and 64 KiB UTF-8. Unknown enum values or unexpected types refuse.
`paragraph.ordinal` is the zero-based paragraph index within the selected
story, in deterministic XML document order, including empty paragraphs and
table-cell paragraphs. `xmlId` is null or the literal eight-hex `w14:paraId`,
canonicalized uppercase. Ordinal is always present: paraId alone is not
unique or stable enough. If provided, xmlId must equal the selected node's
attribute; missing, duplicated or mismatched IDs do not trigger a search.
Thus identical paragraphs remain distinct. `start < end <= codePointLength`
selects one paragraph, end exclusive. A multi-paragraph selection is an
ordered nonempty list of these ranges, with no cross-paragraph offsets.
No auto-merging or silent clipping. Empty paragraph references may be shown
in a reading page but cannot manufacture a nonempty quote locator.
`extractor.revision` is the digest of a canonical build manifest containing
extractor source blob IDs, projection rules revision and locked parser/ZIP
library versions. The release pins that manifest and independently verifies
its digest. It is not caller-supplied executable code or a mutable version
label. Resolver requires that exact available revision; changed extraction
rules create a new revision, never silently reinterpret an old locator.
A paragraph extraction record is exactly:
```json
{
"part": {"class": "footnote", "name": "word/footnotes.xml", "story": "7"},
"paragraph": {"ordinal": 0, "xmlId": null},
"textView": "final", "text": "Note text", "length": 9,
"textDigest": "<sha256 UTF-8 text>",
"anchors": [], "wikiTokens": []
}
```
Anchors have the exact shape `{kind,story,offset}`; allowed
kinds are `footnote-reference`, `endnote-reference`, `comment-start`,
`comment-end`, `comment-reference`; story is a canonical decimal signed XML
ID string, offset a code-point boundary in this record. They never grant
access to their target. wikiTokens are exact source slices matching the
literal syntax in section 5, not instructions or external links. All text
and metadata are untrusted document data. Part/story IDs, excerpts and
comment content never enter system prompts as instructions.
## 4. Story classes and package validation
Version 1 accepts transitional WordprocessingML namespace
`http://schemas.openxmlformats.org/wordprocessingml/2006/main`, relationships
`http://schemas.openxmlformats.org/package/2006/relationships`, office
relationship types rooted at
`http://schemas.openxmlformats.org/officeDocument/2006/relationships/`, and
`w14:paraId` from `http://schemas.microsoft.com/office/word/2010/wordml`.
Prefixes are irrelevant; expanded namespace/local names decide. Strict OOXML
and unknown alternate-content choices refuse until a separately pinned
extractor supports them; never guess equivalent namespaces.
| Class | Exact part names | Story selector | Agent exposure |
| --- | --- | --- | --- |
| body | word/document.xml | main | final by default, explicit grant |
| footnote | word/footnotes.xml | w:footnote/@w:id | own story plus owning document grant |
| endnote | word/endnotes.xml | w:endnote/@w:id | own story plus owning document grant |
| comment | word/comments.xml | w:comment/@w:id | only explicit comment scope |
| layout | word/headerN.xml, word/footerN.xml (N positive decimal) | none | denied in v1 |
| supporting | separately declared non-story document | separate identity | C2 tools/scopes only; no DOCX scope inheritance |
| opaque | media, styles, numbering, theme, fonts, custom XML, embedded objects | none | no raw bytes through O2 reader |
Body/note/comment ownership comes solely from relationships in that committed
package, not filenames supplied by a request. A note/comment belongs to one
DOCX, never the book or an adjacent chapter. Reserved separator/continuation
notes (negative IDs and type separator/continuationSeparator) are structural,
not readable stories. Duplicate story IDs, dangling references or overlapping
ambiguous comment ownership refuse extraction. Unreferenced comment/note
stories are not agent-readable. Cross-paragraph comment anchors may be
recorded, but never concatenate their text with body text.
The root `_rels/.rels` must contain exactly one internal officeDocument
relationship to `word/document.xml`. Its matching content type is
`application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml`.
Optional footnotes/endnotes/comments each have at most one matching internal
relationship and their matching `...wordprocessingml.{footnotes,endnotes,comments}+xml`
content type. Their relationship targets must be the exact part names above.
Reject normalized-path aliases, absolute paths, traversal, encoded separators,
backslashes, duplicate ZIP names, symlink entries, encryption and overlapping
ZIP data. Never extract ZIP entries onto the filesystem.
Only internal relationships of types officeDocument, styles, numbering,
settings, webSettings, fontTable, theme, image, header, footer, footnotes,
endnotes and comments are accepted. Resolve internal targets relative to the
owning part within the package, reject escape and validate existence/type.
Reject every external relationship, hyperlink relationship, altChunk,
attachedTemplate, OLE, package embedding, VBA/macro content type or executable
part. Textual literal wiki links need no relationship. Unsupported rich
constructs refuse the agent projection, not the human save: the human can
retain a full document without it becoming readable to agents.
Parse XML without DTDs, external entities, XInclude or network/file resolution.
Reject DOCTYPE/entity declarations. Bound before allocation: compressed blob
32 MiB, total expanded bytes 128 MiB, each entry 16 MiB, 2048 entries,
expansion ratio 100:1, XML depth 128, 1,000,000 nodes, 100,000 paragraphs,
1,000,000 code points per paragraph and 10,000,000 per document. Extraction
has a 5 s host deadline and bounded worker memory of 256 MiB. Exceeding any
bound yields `editor-document-unsupported`, no truncated success. These are
initial server safety ceilings, not grants or business quota settings;
changing them requires reviewed parser evidence. CRC/size mismatches refuse.
## 5. Deterministic reading views
`textView` enum is `final`, `original`, `markup`. Default requests use final.
Final is the accepted-reading projection: include w:t in normal runs and
w:ins/w:moveTo; exclude w:del/w:moveFrom, including w:delText. Original does
the reverse. This does not accept/reject changes or modify the DOCX. Markup
includes both in source order and returns explicit revision spans with kind,
ID and code-point offsets; deletion/source text is data, not an instruction.
Original/markup and comments require separately enabled scopes; final body
permission alone cannot reveal hidden deletions or comment text. Revision
author/date metadata is omitted from agent responses in v1.
Walk direct story paragraphs and table cells in XML order, including nested
tables in their containing cell order. Paragraph ordinals do not change with
view. Concatenate visible text across adjacent runs with no invented spaces.
Preserve decoded Unicode as stored: no NFC, case folding or whitespace
collapse. w:tab yields U+0009; w:br/w:cr yields U+000A; xml:space text remains
literal. Offsets count Unicode scalar values, not UTF-16 units or graphemes;
unpaired surrogates/invalid XML characters refuse. Field instructions,
hidden/vanished text and text boxes are not silently omitted: their presence
in a requested story makes v1 projection unsupported. Field results and
unknown semantic text-bearing elements likewise refuse until specified.
The projection traverses only these containers in the w namespace:
body, footnote, endnote, comment, tbl, tr, tc, p, r, ins, del, moveFrom,
moveTo, smartTag and sdtContent. A document root wraps body; note/comment
part roots wrap their corresponding stories. sdt may contain sdtPr (ignored
properties) and sdtContent only. Hyperlink containers are unsupported even
without a relationship. Unknown text-bearing containers refuse. Nontext
subtrees pPr, rPr, tblPr, tblGrid, trPr, tcPr and sdtPr may be ignored only
if they contain no story text elements, field codes, external references,
objects or unknown namespaces. Structural leaves bookmarkStart/bookmarkEnd,
proofErr, lastRenderedPageBreak and the reference marks below contribute no
text. w:sectPr is layout-only, with relationship targets validated normally.
Formatting-property descendants are a closed W4b release artifact: its full
expanded-name list is included in the extractor manifest and independently
reviewed before enablement; any name absent from that list refuses. This
artifact can narrow the allowed property subtrees, never add a text-bearing
container to this v1 contract. Missing artifact is no readiness, not a
permissive descendant-text scraper.
Reference marks contribute anchors at the current text boundary, not invented
note numbers or inline comment bodies. Each note/comment is separately paged.
A quote cannot straddle a view, paragraph or story. Extract literal
`[[target]]`, `[[target|label]]`, `[[target#heading]]` and `![[target]]`
after concatenating visible runs within one paragraph, so split-run tokens
survive formatting changes. Preserve complete literal bytes as UTF-8 after
XML decoding and code-point start/end; never follow OS paths or external URLs.
Targets resolve only via the host's owned-document resolver, with independent
authorization; unresolved targets stay literal and marked unresolved in human
UI. No link parsing grants reads. Nested/unclosed bracket sequences remain
plain text, and tokens cannot cross paragraph/view boundaries.
## 6. Reading APIs, verification, paging and staleness
These are proposed typed tool schemas over the existing runner socket, not
native WordCraft MCP. Caller/session/business come from the bound broker.
Agents cannot override them using object fields.
- `editor_read_committed({project,document,version,textView,part,cursor,limit})`:
project, document, version and part are required; only textView, cursor and
limit may be omitted. version is exactly `{mode:"latest"}` or
`{mode:"commit",commit}`; part is
one class/name/story triple above; textView defaults final; cursor is null
on first page, limit 1..100 (default 20). No path, blob or draft argument.
- `editor_resolve_citation({locator})`: verify the exact immutable range;
it does not search for a nearby quote. Return exact quote and locator plus
`currentCommit` and `stale` after current authorization.
- `editor_flag` is W5's metadata-only write; W5 must validate the same
locator before storing a flag and may not write into the document.
Read response is exactly `{version:1, business,project,document,commit,blob,
extractor,textView,part,rows,nextCursor,currentCommit,stale}`. Each row has the
extraction record above. The public envelope contains no absolute paths or
raw package attributes. `nextCursor` is null at end; `currentCommit` is the
current published document commit; stale is exactly commit != currentCommit.
A resolver response is exactly `{version:1,locator,currentCommit,stale}`:
the verified quote is the locator's quote, with no separate unverified text.
There is no default "first part" or automatic descent into notes/comments.
Markup additionally requires `revisionSpans` on each
row: array of `{kind,id,start,end}`, kind insert/delete/move-from/move-to,
with null-free canonical decimal IDs and valid boundaries; other views omit
that key. Limits cap output at 64 KiB UTF-8 JSON per response. Never split a
paragraph silently: if one row exceeds the page budget, return
`editor-range-too-large`; a caller may request a checked subrange through a
separately bounded citation, not receive a truncated paragraph masquerading
as complete. Response overflow never returns partial data with success.
Cursor is a host-authenticated opaque token binding principal, session,
project/document, commit/blob, extractor, view, part, next ordinal, grant
revision and expiration (5 minutes). It carries no plaintext content.
Tampering, reuse by another session, wrong request parameters or a revoked
scope refuses. A latest cursor continues its captured snapshot with stale
metadata if a save intervenes; starting again with null cursor returns the
new publication. Cursor is not a capability and grants are rechecked every
page. Repeated ordinal ranges are stable within one committed projection.
Resolver steps: authorize current principal and historical-view scope; load
that commit's manifest/blob; validate package; run the exact pinned extractor;
select exact part/story/ordinal and optional XML ID; select code-point range;
require literal quote equality and SHA-256(UTF-8 quote) equality. Require both
blob and commit membership, not digest alone. Any mismatch refuses
`editor-citation-invalid` without guessed text. After newer publication the
old valid locator returns `stale:true` with old text; it never auto-relocates
to identical paragraphs or another note. Jump opens that committed view with
an explicit old-version label. If current grant no longer allows it, even
old quote resolution refuses and the UI hides cached content.
Errors: `editor-context-unavailable` for missing readiness; `editor-access-refused`
for absent ownership/grant/share (same response for nonexistent or foreign IDs);
`editor-document-unsupported` for parser limits or unsupported features after
authorization; `editor-version-unavailable` for authorized missing history or
extractor; `editor-citation-invalid` for locator/quote mismatch;
`editor-cursor-invalid` for invalid paging; `editor-range-too-large` for size.
Return no source text, XML, path or untrusted parser error in refusals.
## 7. Grants, sharing and all read surfaces
Access is the intersection of bound role tool grant, human-configured
business/project/document scope, principal's live project/universe rights,
requested part/view scope, server readiness and a valid share context for
this conversation. No wildcard across businesses/universes. Human/session
identity comes from existing trusted transport; a request cannot name a human.
The six creative role tool declarations grant the *operation*, never every
chapter. An agent's own supporting-document writer grants no story access.
Human-only sharing policy defaults `visible-chip-per-send`; the chip is
visible and removable and binds document, committed version and selected
verified locators. Before readiness it says editor context unavailable and
sends no document ID, title, path, selection, quote or source metadata.
The server resolves selection against the saved projection; if it cannot
map exactly (including a live edited selection), refuse context attachment.
Never send unsaved text to try to find a match. Free chat can still send with
no editor context if the human removes the unavailable chip.
A successful send creates a server-owned immutable share record scoped to
that message/conversation, human principal, document and exact committed
ranges/views. It narrows the live grant; it does not widen it or authorize a
later version. Every subsequent lookup supplies the server-resolved share
context and intersects current grants. A future send requires another visible
chip under the default policy. A human may explicitly change sharing to
`off` or `conversation-current`: off revokes future reads; conversation-current
permits the named conversation's authorized latest lookups until human
revocation/session end, with a persistent visible indicator. Such an explicit
policy change cannot add document scopes or hide the indicator. Pins only
organize human UI; they never create a share record or turn on sharing.
A range share permits only its ranges, not an entire paragraph/page or comments.
Latest whole-document reads require a whole-document share, never a range chip.
Historical exact shares resolve their old citations while live authorization
holds. A successful save makes the next authorized *latest* lookup use its
new commit, but never silently upgrades an exact earlier share. The UI says
"Saved newer version; share again" where only an old range is shared. No
future edit is disclosed on the strength of an earlier send. Share records
are resolved by broker session/context, not caller-minted IDs.
Before returning raw conversation/session history, replaying a prompt,
resuming/forking a worker or exporting an agent transcript, reauthorize each
O2 attachment/content block. Keep source text out of generic raw session
stores and logs: persist opaque checked references, not rich prompt text.
Host prompt assembly resolves content in memory for the authorized turn.
Agent harness transcripts containing expanded content must stay host-owned,
outside worker/generic roots, encrypted or withheld from raw session tools;
W4b must prove the selected harness does not persist readable O2 text into a
worker-readable store. Otherwise that harness's editor tools remain disabled.
Revocation cannot erase an agent's memory or already delivered output; it
prevents subsequent fetch/replay, and the UI must not promise retrospective
secrecy. Copying an authorized quote into free agent prose is not a new grant:
O2-derived stored outputs retain the same provenance/access envelope.
This is conservative turn-level provenance, not content recognition: every
output of a turn receiving O2 context, and any resumed/forked descendant,
inherits the union of those document scopes. It can be emitted only into a
host-controlled destination enforcing that envelope. Generic supporting-doc
writes, git writes, exports and arbitrary outgoing tools from that turn
refuse unless their destination has the identical enforcement; normal C2
turns without O2 context keep their existing tool semantics. W4b must enforce
this broker-side before dispatch, not ask an agent to self-label a quote.
If the runner cannot isolate that tool set for an O2-context turn, it cannot
receive O2 context. The separate story-write ban always applies.
All O2-derived exports, caches, extraction indexes, previews, temporary files,
source copies and git history are classified with their document, outside
generic read/grep roots and outside Case 2 library mappings. No raw O2 git
clone/read grant; no source copy under a worker's project root. Generic git
or shell tools cannot reach it through shared parent mounts, symlinks,
hardlinks, alternate worktrees, object alternates or exports. Read-only is
insufficient: no store mount in workers. Validate canonical roots, reject
symlink components, and enforce ownership/mode and sandbox mount closure.
A new import/copy needs fresh document grants and sharing. Don't expose
manifest names/document lists through metadata routes before authorization.
Native engine routes remain human-only and cannot be proxied by agent tools.
W4b is blocked if any raw session or generic surface cannot enforce these
rules. Do not compensate by UI hiding, retroactively classifying all Case 2
Markdown as secret or widening its schema. Store and bus migrations are
reviewed together; revoke/invalidate caches by grant revision and verify live
scope again before emission. Cache keys include document, commit/blob,
extractor, part and view; shared extraction cache is never an authorization
cache. Host audit events contain IDs/digests/status only, never content.
## 8. Acceptance matrix and release evidence
Each row needs a named fixture/test and a mutant or bypass probe in W4b's
packet. Independent fixtures construct OOXML directly and recompute Unicode
slices/digests outside the production extractor. Security checks fail closed;
no fixture-only readiness flag may exist in production APIs.
| ID | Evidence required | Negative control / bypass |
| --- | --- | --- |
| A01 | Exact body quote independently recomputed from commit/blob/revision | change quote, digest, blob, commit or extractor; each refuses |
| A02 | Two identical paragraphs and notes have different locators | no content-search relocation; swapped ordinal/story refuses |
| A03 | XML ID absent/duplicate and ordinal behavior | missing/mismatched paraId cannot silently choose another node |
| A04 | Emoji, combining characters, tabs, breaks and split runs | UTF-16 offsets, normalization and whitespace collapse fail |
| A05 | Literal split-run wiki forms retained | no path/URL fetch; malformed/cross-paragraph tokens stay text |
| A06 | Final/original/markup independently verified with moves/deletions | final-only grant cannot read deletion/comment; no document rewrite |
| A07 | Body/note/endnote/comment exact ownership | orphan/dangling/cross-document references and duplicate IDs refuse |
| A08 | Package allowlist, aliases, CRC, XML/ZIP limits | external rel, macro, traversal, symlink, entity, ZIP bomb and timeout refuse |
| A09 | Commit snapshot and publication ordering | next latest after Saved gets new commit; no worktree/draft fallback |
| A10 | Historical resolve stale label | old quote stays old; removed grant/history refuses without text |
| A11 | Pagination complete/deterministic and bounded | tampered/foreign/revoked cursor and oversized row refuse |
| A12 | Cross-user/business/project/universe denial | guessed IDs, same blob elsewhere and copied document grant nothing |
| A13 | Visible range chip exact boundaries and default off before gate | no selection/title/path metadata before readiness; draft mapping refused |
| A14 | Every lookup intersects live grant and share | pin, old send, revoked scope and range-to-whole-document escalation fail |
| A15 | Whole-document latest share explicit; save rechecked | exact share cannot reveal future edits; persistent policy visible |
| A16 | Generic file/search/git/export/index paths denied | source copy, symlink, hardlink, alternate object root and new O2 copy probes |
| A17 | Raw session, fork/resume and transcript boundaries | revoked context cannot replay via raw tool or harness history |
| A18 | Agent tools exactly read/resolve/flag after gates | native MCP, execute, save, replacement and story comment writes refuse |
| A19 | Existing C2 Markdown reads/line cites unchanged | no DOCX-as-lines conversion or redirect following adoption |
| A20 | Adoption gate before/after readiness and Q36 disposition | absent/revoked server readiness and required notice readiness refuse |
| A21 | Human full-feature save remains separate | unsupported projection yields no agent context, never lossy human save |
| A22 | Scope revocation during extraction | final pre-emission check prevents async stale-authority leak |
Implementation acceptance includes writing, bus, harness, business, WebUI,
seat mount/import closure and stack-copy/release fixtures; explicit production
adoption refusal/lift tests; independent parser/policy/tool review; G1 and
Sage's allocated schema edge. Evidence must name skips and refuse enablement
when an acceptance guard was not exercised. W4's review alone lifts nothing.
## 9. Handoff and unresolved decisions
Megavolt reviews this separate document and records a verdict in
`docs/reviews/2026-10-22_q195-docx-citations.md`. Revisions repeat direct
commit/blob handoff to Megavolt and Sage. Q36 remains with Jason through Mos;
no adoption, C2 reader/reply notice change or schema reinterpretation ships
on this document. W4b's brief must allocate ownership for every surface in
A16/A17 before code: an unowned bypass is a blocker, not a deferred security
note. No live files, editor source imports, parser execution or tests were
used to write this design.