Gevell Tags engine: architecture decision record
Epic: #3866 (Phase 1, Engine). Status: accepted for Phase 1. Decisions marked open need Larry’s call (see the last section).
Code: engine/ (Cargo workspace) and packages/tag-engine-web (the browser client, §8).
Context
Gevell Tags is a standalone PDF tag-tree editor for professional remediators. It targets PDF/UA-1 now and PDF/UA-2 later. One Rust engine is compiled to WASM for the browser, so edits are instant and the document stays local, and native for the server and CLI (validation, batch, AI passes, replay). Both builds run the same code.
Hard requirements:
- Lossless round-trip. Structure the engine does not understand passes through untouched.
- Pixel identity on save, unless the user explicitly overrides it.
- Every edit is a typed, invertible, serializable op.
- 1,000-page documents: op apply plus incremental revalidation in under 50 ms, and incremental save.
Prior art in this repo (read before changing the design)
| Where | What we reuse |
|---|---|
apps/tag-editor/src/struct/{model,parse,build}.ts | Tree model that keeps unknown keys (the lossless pass-through principle); rebuilds the ParentTree on save |
apps/tag-editor/src/edit/{commands,history}.ts | Apply/revert command pattern with no partial states. The engine op log generalizes it. |
apps/tag-editor/src/save/{incremental,verify,content-scan}.ts | Incremental-update writer (output always starts with the original bytes) and a verifySavedFile checklist |
apps/tag-editor/src/inject/content-marker.ts, struct/orphans.ts | Marking untagged content; orphan detection |
services/flyer-tagger/flyer_tagger/inject.py (Injector._rewrite), promote.py, decorative.py | Proven pixel-identical content-stream rewriting; the single-op Artifact/Figure swap; the invoking-level form-XObject tagging policy |
services/flyer-tagger/flyer_tagger/structure.py (StructureContext) | ParentTree builder |
services/flyer-tagger/flyer_tagger/verify.py (pixel_diff) | Production gate numbers: 150 dpi, channel tolerance 2, at least 99.95% identical pixels per page |
services/verapdf, workers/api/src/services/pdf-ua-claim-gate.ts | veraPDF as the authoritative, fail-closed PDF/UA-1 gate |
tools/pdf-inventory/src/census/ua1_rules_catalog.json | UA-1 rule catalog keyed by veraPDF rule ID |
Nothing in the repo splits an MCID at a word boundary or un-artifacts text. Both are new work.
1. Crate layout
engine/ Cargo.toml workspace; lints: unsafe_code=forbid, clippy unwrap/expect/panic=deny crates/ tag-engine-cos/ our own COS layer: lazy reader (xref tables/streams, hybrid, object streams, reconstruction, Standard-handler encryption, filters) and the incremental-update writer (see section 2) tag-engine-core/ COS adapter (pdf/), structure model (model.rs), structure + page tree readers, ParentTree reader, content lexer + marked-content scan, cross-checks (validate.rs). No I/O, no threads: builds for wasm32. tag-engine-ops/ op schema (OpRecord, Op, Provenance, NodeKey); apply/invert/validate tag-engine-geom/ fonts / ToUnicode / text-state simulation, glyph-run geometry per MCID tag-engine-render/ pixel-gate render diff (PDFium, native only) tag-engine-wasm/ wasm-bindgen shim (cdylib); coarse API, see section 8 tag-engine-cli/ `tag-engine` binary: `dump`, `diff`; `apply`, `replay`, `validate`, `save` next planned: tag-engine-validate incremental UA-1 rule engine keyed to veraPDF rule IDsDependencies point one way (as in each crate’s Cargo.toml): core depends on cos; ops, geom, wasm and cli each depend on core directly; cli also depends on render. cos has a dev-dependency on core, for its round-trip tests only. The planned validate will sit on core too. Since #3890 (2026-09-28) the engine has one COS library, tag-engine-cos, for both reading and writing; lopdf is gone from the workspace (see section 2). core/src/pdf/ is the adapter: PdfFile wraps a lazily read CosDocument, and the read-only PdfFile::cos / Cos / CosDict / CosArray view is what tag-engine-geom uses. The model types contain no COS types.
2. PDF object library
| Library | License | WASM | Xref / object streams | Incremental update | Assessment |
|---|---|---|---|---|---|
| tag-engine-cos (ours) | Proprietary; deps MIT/Apache only (flate2, weezl, RustCrypto) | Yes, verified locally (cargo build --target wasm32-unknown-unknown; no CI job yet) | Classic, xref streams, hybrid (/XRefStm in every section), /Prev chains, object streams, scan reconstruction; lazy | Yes: our writer, same kind of xref as the newest section, re-encrypts with the file’s key | Selected (#3890). Reals are f64 plus source lexeme; strings keep their spelling. |
| lopdf 0.45 | MIT | Yes (with wasm_js) | Reads both; object streams expanded into the object map on load | IncrementalDocument appends objects | Used in wave 1, removed 2026-09-28. Bugs below. |
| pdf (pdf-rs) 0.10 | MIT | Pure Rust | Reads both, lazily | Limited | Typed-by-derive model is awkward for unknown-key pass-through; the write path is less proven. Not selected. |
| hayro-syntax 0.7 | Apache-2.0/MIT | Pure Rust | Yes, lazy | No (read-only) | Robust lazy reader from the hayro renderer; MSRV 1.92 (we are on 1.89). Not selected: we wrote our own lazy reader (tag-engine-cos). |
| pdf-writer (typst) | MIT/Apache | Yes | Write only | n/a | Useful for serializing new objects; no reader |
| oxidize-pdf 5.x | MIT | Pure Rust | Yes | Unclear | Young, broad scope; not enough track record |
| qpdf | Apache-2.0 (C++) | No | Yes | Yes | The gold standard for structure-preserving rewriting. Use it as a test oracle (qpdf --check, --json), not a dependency. |
| mupdf crate | AGPL-3.0 | Via emscripten | Yes | Yes | Rejected. Shipping it in a client bundle would put the product under AGPL. |
Decision (Larry, 2026-09-28, #3890): one COS library, ours. lopdf is removed.
Wave 1 read with lopdf and wrote with tag-engine-cos. Building the writer exposed lopdf bugs that matter for an editor:
- Hybrid-reference files: lopdf reads
/XRefStmonly from the newest trailer. After any incremental update whose trailer does not repeat it, every object in the cross-reference stream disappears. Onab-ramirez-bio.pdfcore lost the whole structure tree. - Owner password, AES-128: lopdf derived a wrong key from the owner password, so strings and streams came out garbled.
- Reals are
f32, and its writer printed14.0as14, so coordinates lost digits and reals became integers. - Eager load: every object and object stream was decoded into a map on open.
Reading and writing now share tag-engine-cos. The reader parses the xref data on open and each object on first use. core’s PdfFile keeps parsed objects in per-object OnceCell slots, so borrowed views stay valid, and it parses a whole object stream the first time any member is needed, so the stream is decoded once. Filters: Flate and LZW (with PNG/TIFF predictors), ASCIIHex, ASCII85 and RunLength. Image-only filters (DCT, JPX, CCITTFax, JBIG2) are never decoded: their bytes pass through as opaque data for images and never fail a structure load, but Stream::decode_content / CosDocument::decode_content (for content streams, forms, CMaps and object streams) refuse them. A stream-level /Crypt filter decodes only when it names /Identity (or no /Name). Truncated or corrupt tails (Flate, LZW, RunLength, a short last predictor row) keep what decoded and are reported (Decoded::truncated) instead of passing as complete; an indirect /Filter or /DecodeParms is resolved through the document or is an error, never ignored. Hardening: every filter stage is bounded by Limits::max_stream_bytes (bomb guard), a chain has at most 8 filters and all stages together may produce at most twice the limit, predictor work and buffers follow the data actually present (a row wider than the limit is refused), nesting ≤ 100, reference chains ≤ 128 in core (32 in CosDocument::resolve), visited sets on the /Prev chain, page tree, ParentTree and structure tree, a page-tree depth limit, and a guard against object streams whose /Length lives inside themselves. It is pure Rust with permissive dependencies only, and builds for wasm32-unknown-unknown. MuPDF and any other AGPL code stay out.
Regression tests: core/tests/reader_regressions.rs covers a hybrid file after our own update, after a third-party update without /XRefStm, and with both stacked; and an AES-128 file (made with qpdf --encrypt) opened with the user and with the owner password. The core corpus snapshot did not change. Model and geometry for every fixture are identical to the lopdf build within 1e-5 relative; the only differences are the f32 → f64 real values.
The editor still never full-rewrites a user’s file. Save is an incremental update: the original bytes are followed by only the objects we changed, the way apps/tag-editor/src/save/incremental.ts does it. CosDocument::write_full (decrypted, object and xref streams expanded) exists only for tests and tools; it fails rather than drop an object it cannot read (write_full_report lists them).
Measured on docs/standards/ISO_32000-2_sponsored_EC3.pdf (19 MB, 1,023 pages, 78,468 structure elements, 44,955 MCRs, 44,977 marked-content sequences), natively in release on the same machine:
| Step | lopdf (wave 1) | tag-engine-cos |
|---|---|---|
| Open, model build, content scan of every page, full cross-check | 0.66 s | 0.48 s |
| Whole-document geometry | 0.58 s | 0.53 s |
The ADR’s earlier 1.46 s figure came from a different machine. Opening is allowed to be O(document). Ops must be O(delta) (section 5).
3. Rendering for the pixel-identity gate
The gate has two layers.
-
Paint-stream equivalence (primary, engine-internal, runs in WASM in milliseconds). Structural edits only insert or remove marked-content operators (
BDC/BMC/EMC/MP/DP), or split a text-show operator at a glyph boundary. After a rewrite, re-tokenize the old and new streams, drop the marked-content operators, and require:- the remaining operator sequences to be byte-identical, or
- for split shows, an identical glyph-code sequence under the same simulated text state (Tm, Tf, Tc, Tw, Tz, TL, Ts).
This proof does not depend on any renderer. It catches every accidental paint change the rewriter could make.
-
Render diff (belt and braces), using the production spec from
flyer_tagger/verify.py: 150 dpi, channel tolerance 2, at least 99.95% identical pixels per page. Only pages whose content streams or resources changed are rendered.- Client: pdf.js (Apache-2.0; already used by
apps/tag-editor) in a worker renders before and after. - Server (authoritative): PDFium through
pdfium-render(PDFium is BSD/Apache) in the native container job, plus veraPDF. - hayro (pure Rust, Apache/MIT) is the candidate to render identically in both WASM and native. We evaluate it when the toolchain moves to Rust 1.92 or later.
- Client: pdf.js (Apache-2.0; already used by
The result is a per-page report, not a boolean. GateReport { pages: [{ page, streamsChanged, identicalPct, diffBBoxes, pass }], paintEquivalence: [...] }. Default behavior: any failing page blocks the save. The user may choose “Save with pixel changes”. The engine then saves, the server runs the render diff and veraPDF on the saved file, and every changed page (with its diff regions) goes into the version’s change report and the op log (GateOverride record).
Licensing flag: services/flyer-tagger already depends on PyMuPDF (AGPL-3.0) server-side. That is a separate exposure that should get a legal read. Gevell Tags must not add MuPDF anywhere, client or server.
4. Data model
This is implemented in core/src/model.rs. The whole model is JSON-serializable and PartialEq, and a JSON round-trip is tested on every fixture.
StructureModel { schemaVersion, pdfVersion, encrypted, markInfo, lang, pages[], tree?, content[], diagnostics[], stats }StructTree { obj, kids[ElemId], elements[] (pre-order arena), roleMap, classMap, parentTree: BTreeMap<i64, ParentTreeValue>, parentTreeNextKey }StructElem { obj?, parent?, depth, s, stdType? (RoleMap-resolved, PDF 1.7 set), page?, id, lang, alt, actualText, expansion (E), title (T), attributes[AttributeObject{owner, revision, entries}], classes[], kids[Kid], extra{unknown keys} }Kid = Elem{id} | Mcr{mcid, page?, stream?} | Objr{obj, page?} | Invalid{reason}, wherestreamis/Stmfor MCRs inside form XObjectsPageContent { page, sequences[McSequence{mcid, tag, stream: StreamKey, paintOps}], artifacts, paintOps, untaggedPaintOps, parseError? }, whereStreamKey = Page{page} | XObject{obj}Diagnostic { code, severity, message, page?, obj?, elem? }. Codes cover orphan and dangling MCIDs, duplicates, nesting, ParentTree mismatch, unreachable ParentTree elements, missing StructParents, annotation/OBJR mismatches, untagged pages and content, cycles, limits, and RoleMap cycles or unmapped types.
Principles:
- Orphans are first-class. Content MCIDs that the tree does not reference, and painted content outside any MCID or Artifact, are reported per page. They are not dropped.
- Nothing is silently fixed on read. The model describes the file as it is; fixes are ops.
ElemIdis an arena index, valid only within one model build. Persistent identity isNodeKey:Obj{obj}for existing elements, orNew{id}minted by the op that created the element. Object numbers for new elements are assigned at save time.
Planned additions:
- Geometry, from
tag-engine-geom. For each marked-content sequence: glyph runs (bbox in page space, font, Unicode through ToUnicode or encoding), images, and path bboxes. These are produced by text-state and CTM simulation, the same approach as flyer-tagger’s matrix simulation. Geometry is served to the UI as typed arrays. - The IDTree.
- PDF 2.0 namespaces, for UA-2 (open).
5. Op model
Schema: tag-engine-ops (OpRecord, Op, Provenance, NodeKey, OP_SCHEMA_VERSION).
OpRecord { schemaVersion, seq, opId (client ULID, idempotent append), group?, baseHash (SHA-256 of base PDF), author, timestampMs, provenance, op, inverse }Provenance = user | ai{model, confidence, suggestionId, autoApplied, autoApplyThreshold?} | rule{ruleId} | replay{sourceLog, sourceSeq}Ops:
- Structural:
retag,setAttr,setProp(Alt/ActualText/Lang/E/T/ID),createElem,deleteElem(kids re-parented or deleted explicitly),wrap(nodes → new parent),unwrap,move(node, parent, index),reorder,splitElem,mergeElems. - Tables:
setScope,setHeaders,setSpan, and insert/delete row or column over existing cells. - Annotations:
linkObjr/unlinkObjr. - Content-stream (with a byte patch):
splitMc(stream, mcid, glyphIndex) → newMcidwrapUntagged(stream, opRange) → newMcid + elemartifact(mcid)/unartifact(opRange)setArtifactType
Invariants. Each is enforced in code and by property tests.
- Validated.
validate(op, state)checks preconditions (the node exists, the type is legal, the index is in range, the glyph index is inside the run) and returns a typed error. No op is partially applied. - Invertible.
applycaptures the inverse from the pre-state and stores it in the record, so undo never re-derives it. Property:invert(apply(s, op)) == s, compared on the serialized model and on the patched content bytes. - Serializable. serde JSON, camelCase, with a schema version. Byte patches are base64 plus a SHA-256 of the pre-image, so replay can detect drift.
- Deterministic. New MCIDs are
max(stream) + 1, never reused within a lineage. NewNodeKeys come from a counter in the log. No clocks or randomness in apply. - Invariant-preserving. After every op, the same invariant checker used by the corpus tests runs in debug and test builds: tree shape, ParentTree agreement, and every MCR resolves.
- Grouped. “Fix all” and multi-select edits share a
groupand undo as one.
AI edits. AI suggestions are ops with provenance = ai. Auto-apply is a user setting, off by default. The threshold has a floor of 0.80 (for example, “auto-apply at 0.90 or above”), and org admins can lock auto-apply off. The setting and the policy live in the settings schema (aiAutoApply { enabled, threshold ≥ 0.80, lockedByOrg }), not in the op. Each auto-applied op records autoApplied = true and the threshold in effect. History can filter and bulk-undo by provenance.
Replay onto a revised document. Ops address NodeKey plus the content anchor (stream, glyph-run fingerprint), not arena indices. A replay reports each op as applied, conflicted, or no-op.
6. ParentTree / StructParents maintenance
The engine treats the ParentTree as derived data it owns.
- On load,
parent_tree.rsreads the number tree (bounded depth and node budget, visited set, duplicate keys reported).validate.rscross-checks every stream: page streams and form XObjects with/StructParents, in both directions (tree to ParentTree to content), plus annotation/StructParentagainst OBJR. - In memory, it maintains the indices
(StreamKey, mcid) → NodeKeyandannot → NodeKey. Ops update these indices, never the ParentTree objects directly. - On save, only the per-stream arrays whose contents changed are re-emitted as new versions of the same indirect object. The number-tree leaf is re-emitted only if keys were added. New streams take
ParentTreeNextKey, then increment it. MCIDs are not renumbered on save (open: an optional “compact MCIDs” op). - After save, the file is reloaded and the full cross-check runs. It must report zero ParentTree, orphan and dangling diagnostics that were not already present before the edit.
- Form XObjects: the reader accepts both invoking-level tagging and
/StmMCRs. When the writer tags content inside a shared form, it follows the flyer-tagger policy of wrapping at the invokingDo, because Acrobat and GrackleGo mishandle/StmMCRs.
7. Content-stream tokenizer and rewriter
Tokenizer (implemented, core/src/content/lexer.rs). A total lexer: it never panics and never indexes out of bounds (clippy::indexing_slicing is denied in core).
- Produces
Operation { operator, operands, span }, wherespanis the exact byte range in the decoded stream. - Reads a stream exactly as pdf.js does (#3952, #3942), because the page canvas is pdf.js. It is a port of pdf.js’s
Lexer,ParserandEvaluatorPreprocessor.read. Keywords end at their longest known-operator prefix (qBIisq+BI). Numbers use pdf.js’sgetNumber. A fixed-arity operator takes the operands next to it (the last ones); extras are lent to a later operator that is short, and an operator still short is skipped. Unknown keywords leave their operands pending, and anulloperand is dropped. Where pdf.js throws (a stray), more than 33 operands, a malformed number, an unterminated array), the stream ends. - Junk counts every construct pdf.js and PDFium may read differently, not only bytes that do not parse: a split keyword, an unclean number, an unknown operator (except an operand-less one inside
BX/EX), a droppednull, an operand-count mismatch, a repeated dictionary key, a stop. The paint-equivalence check behind every rewrite and the save gate, and the form placement walks, refuse a stream with junk. The scan and geometry follow pdf.js and report it. /Contentsparts: pdf.js concatenates them with nothing between, PDFium with a space.content::join_content_partsjoins them with\nwhen that reads the same as pdf.js; when two parts meet inside a token, string or comment, the page is read as pdf.js reads it and flagged, and content edits on it refuse.tests/pdfjs_oracle.rschecks all of this against pdfjs-dist itself (unit cases, random token soups,/Contentscuts, the corpus).- Operand nesting is capped at 64; deeper input ends the stream.
- Inline images use
/Lor/Lengthwhen present, otherwise theEIdelimiter heuristic.
Scan (implemented, content/scan.rs). Tracks the marked-content stack.
- Resolves MCIDs from inline property dicts or
/Propertiesnames. - Descends into form XObjects: at most 12 levels, with cycle detection, and each structure-bearing form is recorded once.
- Counts paint operators inside MCID sequences, inside Artifacts, and untagged. Duplicate and nested MCIDs are aggregated per stream.
Rewriter (implemented). A splice list of (span → replacement bytes) applied to the decoded stream, then re-encoded with FlateDecode into a new object version.
- Copy-on-write: a stream or form shared by several pages or places is duplicated before editing.
- Edits:
- Insert
BDC/EMCat operation boundaries only. - Split
Tj/TJat a glyph boundary, using the code length from the font: 1 byte for simple fonts, codespace ranges from the CMap for Type0. For example,(ab) Tjbecomes(a) Tj EMC /Span <</MCID n>> BDC (b) Tj. ATJarray is split between elements, and a kerning number carries into the second part. - Artifacting changes only the tag and properties of the
BDC: the flyer-taggerdecorative.pysingle-op swap.
- Insert
- Legality: marked content must nest properly with
BT/ETandq/Q. Inside a text object, marks go around shows, never acrossET. The rewriter rejects a patch it cannot place legally; it does not produce an illegal stream.- Splits across text objects (implemented). A sequence that spans several text objects, paths or
q/Qframes cannot be split by closing and reopening in place, and an MCID names one sequence per stream (reusing it is aDuplicateMcidInContentdefect). The split removes the sequence’sBDC/EMCand covers each side of the split point with the fewest balanced stretches (head stretches walking out of frames that close, one at the side’s shallowest level, tail stretches walking into frames that stay open). The original MCID keeps the first stretch before the split, the new MCID the first after it; other stretches get fresh MCIDs, returned inRewrite.split { before, after }so the op layer lists them on the same structure element. Refused, never produced illegally: a split point inside a nested mark; a show operator outsideBT/ET; a show whose font code lengths are unknown; unbalanced or illegally nested input (marks crossingBT/ETorq/Q, unmatchedBDC/EMC); and a split with a side that paints nothing. Corpus: 99.7% of sampled splits succeed (was 48%).
- Splits across text objects (implemented). A sequence that spans several text objects, paths or
- Verification: every patch passes the paint-equivalence check (section 3) before the op is accepted.
8. WASM boundary
Coarse-grained, and run in a dedicated module Worker. The document lives in WASM memory inside the worker; the main thread never holds a TagDocument, only a numeric DocId, and the UI never walks PDF objects. Implemented in Phase 1 (engine/crates/tag-engine-wasm) and Phase 2 P1 (packages/tag-engine-web, #3960).
8.1 The engine API (TagDocument, tag-engine-wasm/src/bindings.rs)
Inputs cross as JS objects (serialized with JSON.stringify, parsed by serde); outputs are plain JSON objects, except bytes (Uint8Array) and compact geometry boxes (Float32Array). Types: tag-engine-wasm/src/ts/*.d.ts, appended to the generated .d.ts and copied into packages/tag-engine-web/src/generated/engine-types.ts.
| Call | Returns | Notes |
|---|---|---|
engineVersion() | string | Crate version |
TagDocument.open(bytes, password?) | handle | Throws parse, encrypted, limitExceeded only when no model can be built |
summary(), model(), diagnostics() | DocumentSummary, ModelView, DiagnosticsView | Describe the bytes as opened: asOpened, editsSinceOpen, stale in-band; sourceTrusted false after xref repair, checksum failure or an unreadable object |
editTree() | EditTree | The current edited tree (whole tree; deltas are #3961 I1) |
apply(op, meta?), applyGroup(ops, label, options?), undo(meta?), redo(meta?) | EditOutcome | A refusal is returned (status: "refused"), never thrown; groups are atomic by default |
canUndo(), canRedo(), mintKey() | boolean, NodeKey | |
pageGeometry(page, { compact? }) | PageGeometryResult | From the bytes as opened: reflectsEdits false once a content op touched the page (#3961 I2) |
save({ allowUnverifiedContent? }) | { bytes, report } | One incremental update; structural verification only (checksNotRun: qpdfCheck, renderDiff); refusals thrown with typed details |
exportOpLog(fromSeq?), replay(log, options?) | OpRecord[], ReplayReport | baseMismatch thrown for a foreign log unless allowRebase |
free() | --weak-refs is a fallback, not a substitute |
Errors are TagEngineError { code, message, details } objects.
8.2 The client contract (packages/tag-engine-web)
The workspace codes against EngineClient / EngineDocument (src/client.ts) only. Its methods map 1:1 onto TagDocument, with three changes at the boundary:
- Refusals are outcomes, failures are rejections. Op refusals were already returned. The client also turns save refusals (
unverifiedContent,saveUnsupported) into{ status: "refused", error }and a replaybaseMismatchinto{ status: "refused" }. The post-save verification failures (saveNewDiagnostics,saveStructureMismatch,saveContentMismatch) mean the engine wrote a wrong file, so they reject, as they fail in the CLI. Everything else rejects with anEngineClientErrorwhosefailure.kindisengine(the engine threw, with its code),crashed,documentLost,closed,unsupportedorprotocol. - Optional capabilities (#3961).
EngineInfo.capabilitieslists what this engine build adds:deltas,editedGeometry,paintOpIndices,contentMap,documentText,op:setDoc,setTabOrder,annotations,op:setAnnot,op:setRoleMap,op:setClassMap,restore,checkStructure,pagesAffected. They come from anengineFeatures(): string[]export when the engine has one (authoritative; unknown names are reported, not dropped). Without it, only method-shaped capabilities are detected, by probingTagDocument; behaviour-shaped ones (deltas, new op kinds, edited geometry) are reported absent. The client refuses a call or an op kind that needs a missing capability withunsupported, without sending it. Provisional shapes for these calls live insrc/protocol.tsuntil #3961 lands its.d.ts; a test flags them once the engine declares them. - No handle on the main thread.
openreturns anEngineDocumentbound to aDocIdand a worker generation.
8.3 Worker and RPC
- Worker:
src/engine.worker.tsimports thewasm-bindgen --target webglue through the package import#wasm-glue(pkg/tag_engine_wasm.js, built byscripts/build-wasm.sh). The glue finds the binary withnew URL('tag_engine_wasm_bg.wasm', import.meta.url), which the bundler emits as a hashed static asset. The page’s CSP needs'wasm-unsafe-eval'inscript-src. - Protocol (
src/protocol.ts):{ kind: "request", id, method, args }→{ kind: "response", id, ok, result | failure }, plus an unsolicited{ kind: "fatal", reason }.EngineMethodstypes every method’s arguments and result. - Serial queue: the worker host (
src/host.ts) answers one request at a time, in arrival order, even when a backend call is asynchronous. The client posts every document call synchronously (capability checks are synchronous), andopen/restoretoo oncehellohas answered, so calls reach the engine in submission order. - Transferables: results the worker no longer needs (
save().bytes, compactboxes) are transferred, not copied; only typed arrays that own their whole buffer are transferred (never a view into WASM memory).open(bytes, { transfer: true })hands the input over; the default copies. - Crash and restart: any exception from the engine that is not a
TagEngineError(a trap, a stack overflow, a wasm-bindgen borrow error: the instance can no longer be trusted), a worker that cannot be created, an uncaught worker error, amessageerror, a failed wasm load, a malformed message, ahellowith another protocol version (sticky: no new worker is started from the same stale bundle, and every later call rejects withprotocol), or a watchdog timeout (no answer forcallTimeoutMswhile calls are pending; default 120 s, reset by every answer) terminates the worker. Every pending call rejects withcrashed,onCrashlisteners get the lostDocIds, and later calls on those documents reject withdocumentLostwithout starting a worker. The nextopen/readystarts a fresh worker (a new generation). The exception is the fatal protocol mismatch above: then every call, document or not, rejects withprotocoland no worker is started.restart()does the same on purpose. No call waits forever. - Mock:
createMockEngineClient(fixtures)runs the same host and protocol over an in-process transport (structured clone, async delivery) with a replay backend. Fixtures (packages/tag-engine-web/fixtures/*.json) are transcripts recorded from the real wasm engine byscripts/record-fixtures.tsover PDFs indocs/standards/. The mock recognizes the opened bytes by SHA-256 and answers only calls recorded at the document’s current step; anything else throwsmockUnrecorded. It never invents a result. - The WASM build is single-threaded (no rayon); parallelism, if ever needed, is page-range workers.
8.4 CI (#3931)
The engine job in .github/workflows/test.yml runs on PRs touching engine/**, packages/tag-engine-web/**, packages/tags-core/** or any *.pdf, inside engine/ci/Dockerfile (Rust 1.89, wasm-bindgen-cli at the Cargo.lock version, Node 22, and the dev Macs’ oracle versions: qpdf 12.3.2, libpdfium from pypdfium2 5.13.0, veraPDF 1.30.2): cargo fmt, clippy (native and wasm32), cargo test --workspace --release with TAG_ENGINE_REQUIRE_ORACLES=1 and TAG_ENGINE_REQUIRE_PDFIUM=1 (any skip line fails the step; the one exception is tag-engine-geom’s oracle_agreement, whose snapshot encodes macOS font substitution for non-embedded base-14 fonts: it runs as a separate, non-blocking step reported as not verified on Linux, #3985), the wasm32 build and wasm-bindgen-test (every test in tests/web.rs must pass), the pkg/ build, and the package’s unit and real-engine tests. The real-engine tests drive the wasm build through the protocol, load engine.worker.ts itself in a separate test file (stubbed worker scope; the test asserts init() fetched the .wasm asset), and re-record every fixture against the current engine, failing on any difference. It also runs packages/tags-core’s real-engine tests in the image: every op-builder against the native CLI and the mirror property test against the wasm build (see tags-core.md). Not covered: the worker in a real browser (planned with the apps/tags shell, #3962). The required gate check depends on this job.
Not yet in the browser path (planned, not implemented): gateInputs(pages) for a client-side render diff; pixel identity is verified server-side.
9. Persistence and server sync (AWS; auth stays on Supabase)
Amended 2026-10-01 for #3969, as built (details: sync-api.md). The proposal below assumed API Gateway with a Lambda authorizer and a DynamoDB Streams replay Lambda. The real host is the existing API Lambda behind api-pdf.gevell.com, which verifies the Supabase JWT itself.
The engine runs client-side. Documents, versions and op logs sync to the server, and the op log is kept for the life of the document (never trimmed).
| Data | Store | As built |
|---|---|---|
| Op log | DynamoDB, the existing single-table MainTable (pk/sk, PITR) | pk = DOC#<docId>, sk = OP#<seq, 12 digits>. A conditional put (attribute_not_exists(sk)) and a META condition (headSeq = baseSeq) in one transaction enforce ordering; a conflict is 409. Records over 350 KB go to S3 (ops/<seq12>-<opId>.json), and the item keeps s3Key + sha256. |
| Lease | same partition, sk = LEASE | Single editor per session: 15 s heartbeat, 45 s expiry. Checked inside every append and version transaction. |
| Versions | same partition, sk = VER#<n> (n = 0 is the base) | Status, upToSeq, declared SHA-256 and size, the S3 VersionId pinned at commit, and the verification result |
| Document metadata and ACL | same partition, sk = META, plus a TAGUSER#<userId> index item | Owner-only in Phase 2. Other callers get 404. A Phase 2 allowlist flag (TAGS_ALLOWLIST, user ids) gates the whole API. |
| PDFs and reports | S3, a dedicated versioned bucket, SSE-KMS with its own CMK | tags/docs/<id>/base.pdf, versions/<n>.pdf, reports/<n>/verify.json. Every transfer is a presigned URL for one key: PUTs sign the SHA-256 checksum and If-None-Match, GETs pin the committed version. |
| Server verification | SQS → tags-verifier container on the EC2 anchor | Per saved version, in order: tag-engine replay (the uploaded bytes must equal the log replayed onto the base), qpdf --check, the PDFium diff of every page, and veraPDF UA-1. A missing or failing oracle is “could not verify”, never a pass. |
Not built yet: model checkpoints every N ops and the DynamoDB Streams replay Lambda. Replay runs only in the verifier, per version, from the base. Add checkpoints when a log’s replay time matters.
Trade-offs
- DynamoDB gives cheap ordered appends, conditional writes and transactions in a table we already operate. Its downsides are the 400 KB item limit (hence the S3 offload) and the 100-action transaction limit (hence at most 90 ops per append).
- Postgres on Supabase would be simpler to query, but it would put document content on the auth provider. We prefer AWS for data residency, which fits the university security posture.
- Auth: the Supabase JWT is verified in the Lambda by the shared
requireAuthmiddleware, the same as every other route. There is no API Gateway authorizer. The document ACL lives in META. - Verification in a container instead of Lambda: veraPDF (Java) and the PDFium render of a 1,000-page document exceed what Lambda does well. The anchor is always on, and the oracle versions match the engine CI image.
10. Testing strategy
-
Round-trip corpus (implemented,
core/tests/corpus.rs). Every tracked*.pdfexcepttools/pdfpolish/, which may hold customer-derived output; 54 files including 14 tagged ISO/PDF Association documents. For each fixture:- structural invariants: shape, pre-order, parent and depth links, no placeholder kids;
- every MCR resolves or is reported;
- an independent ParentTree recheck, where any disagreement must be reported (no false passes);
- a stats recount and a JSON round-trip;
- load → save → load model equality;
- count snapshot
tests/snapshots/corpus.json, regenerated withTAG_ENGINE_UPDATE_SNAPSHOTS=1.
A clean reference document (Tagged PDF Best Practice Guide) must have zero errors.
-
Hostile inputs (implemented,
core/tests/hostile.rs): struct-tree cycles, a 5,000-deep chain (bounded at depth 512), ParentTree and page-tree self-cycles, a self-invoking form XObject, a RoleMap cycle, garbage input, and a 300-case deterministic byte-mutation and truncation sweep that asserts no panic. -
Next:
proptestrandom op sequences: apply then invert equals identity; apply, save, load equals the model; replay is deterministic.cargo-fuzztargets for the lexer andopen.- Render-diff golden tests.
- veraPDF over saved outputs in CI.
- qpdf / pikepdf as a differential oracle for ParentTree conclusions.
criterionbenches on ISO 32000-2 for op apply plus revalidate (budget 50 ms) and incremental save.
-
Validation parity: the Rust UA-1 checker ports tree-level rules keyed to veraPDF rule IDs (
ua1_rules_catalog.json). It reports unchecked rules explicitly instead of passing them, and veraPDF stays authoritative on save and export.
Open questions (need Larry)
- Object layer: resolved 2026-09-28 (#3890): our own lazy COS reader and writer (
tag-engine-cos); lopdf removed. See section 2. - Renderer: pdf.js client plus PDFium server (recommended), or bet on hayro everywhere (identical pixels in WASM and native; requires a toolchain move to Rust 1.92 or later)?
- PyMuPDF (AGPL) in flyer-tagger: get a legal review, or plan a migration to PDFium?
- MCID policy on save: preserve existing MCIDs (recommended), or offer a “compact MCIDs” op?
- Encrypted input: partly resolved (#3890):
tag-engine-cosre-encrypts appended objects with the file’s own key and methods (RC4-40 through AES-256 R6, tested against qpdf with user and owner passwords). Still open: whether the product accepts encrypted input by default or asks for an unlocked copy. Thepassword.pdffixture opens only with a password. - Collaboration: is single-editor lease plus op log enough through Phase 5, before choosing OT or CRDT in Phase 6?
- PDF/UA-2 model: namespaces per element now (a schema bump later is costly), or deferred?
- Product name: decided — Gevell Tags at tags.gevell.com (add to the product registry when it ships).