Skip to content

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)

WhereWhat we reuse
apps/tag-editor/src/struct/{model,parse,build}.tsTree model that keeps unknown keys (the lossless pass-through principle); rebuilds the ParentTree on save
apps/tag-editor/src/edit/{commands,history}.tsApply/revert command pattern with no partial states. The engine op log generalizes it.
apps/tag-editor/src/save/{incremental,verify,content-scan}.tsIncremental-update writer (output always starts with the original bytes) and a verifySavedFile checklist
apps/tag-editor/src/inject/content-marker.ts, struct/orphans.tsMarking untagged content; orphan detection
services/flyer-tagger/flyer_tagger/inject.py (Injector._rewrite), promote.py, decorative.pyProven 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.tsveraPDF as the authoritative, fail-closed PDF/UA-1 gate
tools/pdf-inventory/src/census/ua1_rules_catalog.jsonUA-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 IDs

Dependencies 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

LibraryLicenseWASMXref / object streamsIncremental updateAssessment
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; lazyYes: our writer, same kind of xref as the newest section, re-encrypts with the file’s keySelected (#3890). Reals are f64 plus source lexeme; strings keep their spelling.
lopdf 0.45MITYes (with wasm_js)Reads both; object streams expanded into the object map on loadIncrementalDocument appends objectsUsed in wave 1, removed 2026-09-28. Bugs below.
pdf (pdf-rs) 0.10MITPure RustReads both, lazilyLimitedTyped-by-derive model is awkward for unknown-key pass-through; the write path is less proven. Not selected.
hayro-syntax 0.7Apache-2.0/MITPure RustYes, lazyNo (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/ApacheYesWrite onlyn/aUseful for serializing new objects; no reader
oxidize-pdf 5.xMITPure RustYesUnclearYoung, broad scope; not enough track record
qpdfApache-2.0 (C++)NoYesYesThe gold standard for structure-preserving rewriting. Use it as a test oracle (qpdf --check, --json), not a dependency.
mupdf crateAGPL-3.0Via emscriptenYesYesRejected. 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:

  1. Hybrid-reference files: lopdf reads /XRefStm only from the newest trailer. After any incremental update whose trailer does not repeat it, every object in the cross-reference stream disappears. On ab-ramirez-bio.pdf core lost the whole structure tree.
  2. Owner password, AES-128: lopdf derived a wrong key from the owner password, so strings and streams came out garbled.
  3. Reals are f32, and its writer printed 14.0 as 14, so coordinates lost digits and reals became integers.
  4. 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:

Steplopdf (wave 1)tag-engine-cos
Open, model build, content scan of every page, full cross-check0.66 s0.48 s
Whole-document geometry0.58 s0.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.

  1. 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.

  2. 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.

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}, where stream is /Stm for MCRs inside form XObjects
  • PageContent { page, sequences[McSequence{mcid, tag, stream: StreamKey, paintOps}], artifacts, paintOps, untaggedPaintOps, parseError? }, where StreamKey = 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.
  • ElemId is an arena index, valid only within one model build. Persistent identity is NodeKey: Obj{obj} for existing elements, or New{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) → newMcid
    • wrapUntagged(stream, opRange) → newMcid + elem
    • artifact(mcid) / unartifact(opRange)
    • setArtifactType

Invariants. Each is enforced in code and by property tests.

  1. 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.
  2. Invertible. apply captures 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.
  3. 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.
  4. Deterministic. New MCIDs are max(stream) + 1, never reused within a lineage. New NodeKeys come from a counter in the log. No clocks or randomness in apply.
  5. 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.
  6. Grouped. “Fix all” and multi-select edits share a group and 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.rs reads the number tree (bounded depth and node budget, visited set, duplicate keys reported). validate.rs cross-checks every stream: page streams and form XObjects with /StructParents, in both directions (tree to ParentTree to content), plus annotation /StructParent against OBJR.
  • In memory, it maintains the indices (StreamKey, mcid) → NodeKey and annot → 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 /Stm MCRs. When the writer tags content inside a shared form, it follows the flyer-tagger policy of wrapping at the invoking Do, because Acrobat and GrackleGo mishandle /Stm MCRs.

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 }, where span is 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, Parser and EvaluatorPreprocessor.read. Keywords end at their longest known-operator prefix (qBI is q + BI). Numbers use pdf.js’s getNumber. 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 a null operand 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 dropped null, 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.
  • /Contents parts: pdf.js concatenates them with nothing between, PDFium with a space. content::join_content_parts joins them with \n when 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.rs checks all of this against pdfjs-dist itself (unit cases, random token soups, /Contents cuts, the corpus).
  • Operand nesting is capped at 64; deeper input ends the stream.
  • Inline images use /L or /Length when present, otherwise the EI delimiter heuristic.

Scan (implemented, content/scan.rs). Tracks the marked-content stack.

  • Resolves MCIDs from inline property dicts or /Properties names.
  • 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/EMC at operation boundaries only.
    • Split Tj/TJ at 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) Tj becomes (a) Tj EMC /Span <</MCID n>> BDC (b) Tj. A TJ array 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-tagger decorative.py single-op swap.
  • Legality: marked content must nest properly with BT/ET and q/Q. Inside a text object, marks go around shows, never across ET. 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/Q frames cannot be split by closing and reopening in place, and an MCID names one sequence per stream (reusing it is a DuplicateMcidInContent defect). The split removes the sequence’s BDC/EMC and 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 in Rewrite.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 outside BT/ET; a show whose font code lengths are unknown; unbalanced or illegally nested input (marks crossing BT/ET or q/Q, unmatched BDC/EMC); and a split with a side that paints nothing. Corpus: 99.7% of sampled splits succeed (was 48%).
  • 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.

CallReturnsNotes
engineVersion()stringCrate version
TagDocument.open(bytes, password?)handleThrows parse, encrypted, limitExceeded only when no model can be built
summary(), model(), diagnostics()DocumentSummary, ModelView, DiagnosticsViewDescribe the bytes as opened: asOpened, editsSinceOpen, stale in-band; sourceTrusted false after xref repair, checksum failure or an unreadable object
editTree()EditTreeThe current edited tree (whole tree; deltas are #3961 I1)
apply(op, meta?), applyGroup(ops, label, options?), undo(meta?), redo(meta?)EditOutcomeA refusal is returned (status: "refused"), never thrown; groups are atomic by default
canUndo(), canRedo(), mintKey()boolean, NodeKey
pageGeometry(page, { compact? })PageGeometryResultFrom 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[], ReplayReportbaseMismatch 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 replay baseMismatch into { 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 an EngineClientError whose failure.kind is engine (the engine threw, with its code), crashed, documentLost, closed, unsupported or protocol.
  • Optional capabilities (#3961). EngineInfo.capabilities lists 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 an engineFeatures(): string[] export when the engine has one (authoritative; unknown names are reported, not dropped). Without it, only method-shaped capabilities are detected, by probing TagDocument; 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 with unsupported, without sending it. Provisional shapes for these calls live in src/protocol.ts until #3961 lands its .d.ts; a test flags them once the engine declares them.
  • No handle on the main thread. open returns an EngineDocument bound to a DocId and a worker generation.

8.3 Worker and RPC

  • Worker: src/engine.worker.ts imports the wasm-bindgen --target web glue through the package import #wasm-glue (pkg/tag_engine_wasm.js, built by scripts/build-wasm.sh). The glue finds the binary with new 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' in script-src.
  • Protocol (src/protocol.ts): { kind: "request", id, method, args } → { kind: "response", id, ok, result | failure }, plus an unsolicited { kind: "fatal", reason }. EngineMethods types 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), and open/restore too once hello has answered, so calls reach the engine in submission order.
  • Transferables: results the worker no longer needs (save().bytes, compact boxes) 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, a messageerror, a failed wasm load, a malformed message, a hello with another protocol version (sticky: no new worker is started from the same stale bundle, and every later call rejects with protocol), or a watchdog timeout (no answer for callTimeoutMs while calls are pending; default 120 s, reset by every answer) terminates the worker. Every pending call rejects with crashed, onCrash listeners get the lost DocIds, and later calls on those documents reject with documentLost without starting a worker. The next open/ready starts a fresh worker (a new generation). The exception is the fatal protocol mismatch above: then every call, document or not, rejects with protocol and 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 by scripts/record-fixtures.ts over PDFs in docs/standards/. The mock recognizes the opened bytes by SHA-256 and answers only calls recorded at the document’s current step; anything else throws mockUnrecorded. 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).

DataStoreAs built
Op logDynamoDB, 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.
Leasesame partition, sk = LEASESingle editor per session: 15 s heartbeat, 45 s expiry. Checked inside every append and version transaction.
Versionssame 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 ACLsame partition, sk = META, plus a TAGUSER#<userId> index itemOwner-only in Phase 2. Other callers get 404. A Phase 2 allowlist flag (TAGS_ALLOWLIST, user ids) gates the whole API.
PDFs and reportsS3, a dedicated versioned bucket, SSE-KMS with its own CMKtags/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 verificationSQS → tags-verifier container on the EC2 anchorPer 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 requireAuth middleware, 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 *.pdf except tools/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 with TAG_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:

    • proptest random op sequences: apply then invert equals identity; apply, save, load equals the model; replay is deterministic.
    • cargo-fuzz targets for the lexer and open.
    • Render-diff golden tests.
    • veraPDF over saved outputs in CI.
    • qpdf / pikepdf as a differential oracle for ParentTree conclusions.
    • criterion benches 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)

  1. Object layer: resolved 2026-09-28 (#3890): our own lazy COS reader and writer (tag-engine-cos); lopdf removed. See section 2.
  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)?
  3. PyMuPDF (AGPL) in flyer-tagger: get a legal review, or plan a migration to PDFium?
  4. MCID policy on save: preserve existing MCIDs (recommended), or offer a “compact MCIDs” op?
  5. Encrypted input: partly resolved (#3890): tag-engine-cos re-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. The password.pdf fixture opens only with a password.
  6. Collaboration: is single-editor lease plus op log enough through Phase 5, before choosing OT or CRDT in Phase 6?
  7. PDF/UA-2 model: namespaces per element now (a schema bump later is costly), or deferred?
  8. Product name: decided — Gevell Tags at tags.gevell.com (add to the product registry when it ships).