Skip to content

Tag-studio salvage assessment

Epic #3866. This is a read-only review of existing code, done to decide what the new engine can salvage. The engine is a Rust core compiled to WASM and to native code. It provides a normalized structure model, typed edit ops with an op log and undo, content-stream rewriting with a pixel-identity gate on save, lossless round-trip, and PDF/UA-1 validation.

The review covers engine-level and validation-level logic, algorithms, rules, fixtures and knowledge only. The UI of the existing editors is out of scope, apart from the pain points in the last section.

Nothing was built or run for this review. LOC figures are line counts. Test counts are grep counts of def test_ / it( / test(.

Verdict key

VerdictMeaning
PORTThe algorithm is worth re-implementing in the Rust core. The existing tests become its spec.
REUSE-AS-ISKeep it and call it (a server-side service), or keep the data (fixtures, rule catalogs).
REFERENCEKnowledge only. Read it before designing the matching feature.
DISCARDNothing to carry forward.

Summary table

ComponentLangSize / testsVerdict
apps/tag-editor engine (src/pdf, struct, edit, save, inject, audit)TS7.3k LOC (about 4.1k engine), about 84 testsPORT (best prior art)
apps/tag-editor/src/sync (PDF.js geometry)TSsmallREFERENCE (stays in JS next to the renderer)
packages/tag-editor-uiTSX6.2k LOC, about 143 testsDISCARD UI; REFERENCE lib/triage.ts, lib/tableGrid.ts, lib/outline.ts
apps/admin/src/components/tag-editorTSXabout 3.2k LOCDISCARD (a divergent copy of the above)
packages/editorTS8.8k LOCDISCARD (HTML/DOM remediation, not PDF)
apps/web /tags and /workspace editorsTSX—DISCARD (UI over flyer-tagger ops)
services/flyer-tagger inject / promote / structure / structedit / decorativePython (pikepdf, PyMuPDF)30k LOC, about 1,700 tests in 107 filesPORT selected algorithms
services/flyer-tagger verify / tree_findings / conformance / tree_assessPythonabout 1.4k LOCPORT rules; REUSE-AS-IS pixel_diff numbers and the veraPDF/qpdf calls
services/flyer-tagger AI pipeline (analyze, textract, ocr_tag, fontsub, tounicode)Python—REUSE-AS-IS server-side (phase 5 AI/bulk)
services/verapdf + workers/api/src/services/verapdf-client.ts + pdf-ua-claim-gate.tsPython/Java + TS128 + 235 + 125 LOC, 19 testsREUSE-AS-IS
tools/tag-lintPython700 LOC, 27 testsPORT (rules and the Form-XObject MCID walk)
tools/pdfpolish repairs (structrepair, tagtree, inlinewrap, docmeta)Python3.8k LOC, about 72 testsPORT as quick-fix ops
tools/pdfpolish/pdfpolish/verifygate.pyPython—REFERENCE (strict and bbox-scoped pixel modes)
packages/pdf-enhancePython1.2k LOC, 63 testsREFERENCE (the invariant-diff idea only)
tools/pdf-inventory/src/census (ua1_rules_catalog.json, clause-class-map.json)JSON/Python106 rulesREUSE-AS-IS as the rule ID space
workers/api/src/services/pdf-accessibility-scorer.tsTS2.1k LOC, 93 testsREFERENCE (check list and WCAG map); do not port the regex-over-bytes code
workers/api/src/services/pdf-manual-risks-structure.tsTS349 LOCPORT as advisory rules
tools/pdfdump/pdfdump.pyPython657 LOC, no testsREFERENCE
tools/tag-pdfNode400 LOCDISCARD (thin client of the tagging API)
WCAG §5.2 conformance reqs (packages/shared/src/conformance-requirements.ts)TS189 LOC, 5 reqsREFERENCE; report layer only. Unmerged, on origin/feature/pdf-conformance-requirements
tools/tag-corpus (#3856, PR #3862)Python24 unit testsREUSE-AS-IS (walker, spec format, record/replay, lint flags). Unmerged, on feature/tag-corpus-tooling
docs/knowledge/print-accessibilityMD + rules.json117 PRINT-* rulesREFERENCE (print-side counterparts to tree rules)
docs/admin/tag-tree-quality.mdMD—REFERENCE, required reading

Component notes

apps/tag-editor: PORT

This is a client-side editor with its own PDF reader and writer. Its design already matches what the Rust core needs, so it is the closest prior art.

PDF layer (src/pdf/*, about 1.1k LOC). REFERENCE only. It is a hand-written lexer, parser and xref reader that handles:

  • classic xref tables, xref streams, hybrid files and object streams;
  • a recovery scan (files opened this way are view-only).

It supports only two filters, Flate (with predictors) and ASCIIHex, and it refuses encrypted files. The Rust core should use lopdf or its own parser, and match this layer’s strict/recovery split.

Struct model (src/struct/model.ts, parse.ts). PORT.

  • Elements live in an arena keyed by NodeId.
  • A kid is a tagged union: element | mcid{mcid,pageKey,stmKey?} | objr{objKey,pageKey?}.
  • Unknown keys are kept verbatim in extra.
  • /A and /C are carried opaquely.
  • ClassMap, Namespaces and IDTree go into rootExtra and are re-emitted untouched. This is the lossless-round-trip pattern to keep.
  • parseKids handles MCR (including /Stm), OBJR and inherited /Pg.
  • The walker detects cycles, caps depth and turns malformed kids into warnings.

Gaps the port must fix:

  • mappedRole follows the RoleMap one hop only.
  • Form-XObject MCIDs are not verified.
  • There is no attribute or ClassMap editing.

Edit ops (src/edit/commands.ts, history.ts). PORT the semantics. It is a command pattern with apply/revert:

  • SetRole
  • SetTextProperty (T, Alt, ActualText, E, Lang)
  • Move (refuses cycles)
  • Wrap
  • Unwrap (refuses content directly under root)
  • Delete
  • Merge
  • Split (structure-level only)
  • Reclaim (orphan MCIDs)
  • Composite (atomic, with partial-apply rollback)

Deleted subtrees stay in the arena, so undo keeps the original identities. This is the seed of the Gevell Tags op log. Known defects:

  • Delete leaves content untagged instead of artifacting it (commands.ts:188).
  • Reclaim appends at the end of the root, which puts reclaimed content in the wrong reading order.

Save (src/save/*, src/struct/build.ts). PORT all of it.

  • buildStructObjects rewrites elements under their original object numbers, so IDTree and /Ref stay valid.
  • buildParentTree rebuilds the ParentTree from scratch.
  • It assigns missing /StructParents.
  • writeIncrementalUpdate writes a true incremental update and matches the file’s xref style.

verifySavedFile is the model for Gevell Tags’ save gate. It refuses to hand back the file unless every check passes:

  • the original bytes are an untouched prefix of the output;
  • the new xref is in effect;
  • the file reparses strictly;
  • every /P back-pointer is correct;
  • the ParentTree agrees with the tree for both MCIDs and OBJRs;
  • every referenced MCID exists in content, including named /Properties;
  • the page count is unchanged;
  • page content is byte-identical.

The one flaw: the ParentTree is a single flat /Nums array. Tag-studio needs a balanced number tree with /Kids for large documents.

Content (save/content-scan.ts, struct/orphans.ts, inject/*). PORT.

  • mcidsInContent is a BDC/MCID scan that skips inline images safely.
  • findOrphanedMcids diffs content MCIDs against tree MCIDs.
  • planMarkerInsertions / injectMarkers / removeInsertions are insertion-only marking. Verification strips the insertions and must get back the original stream exactly. That is a cheap, render-free pixel-safety proof, and it is worth keeping as a first gate in front of the render diff.

The inject path has two limits: it works on fully unmarked documents only, and it groups at line level.

Audit (src/audit/rules.ts). PORT as the first live rules. There are 9 rules:

  • doc-lang
  • nonstandard-role
  • figure-alt
  • heading-skip
  • multiple-h1
  • table-structure
  • table-headers
  • list-structure
  • empty-tag

Tests. The round-trip tests become engine tests. They cover:

  • classic xref, xref-stream, object-stream and hybrid files;
  • OBJR, MCR, RoleMap and IDTree;
  • verifier tamper cases;
  • inject and reclaim.

There is also a real-corpus no-op round-trip over TestFiles/.

services/flyer-tagger: PORT selected algorithms, REUSE the AI pipeline

This is an in-place tagger written in Python. It uses pikepdf for structure and content-stream work (via parse_content_stream/unparse_content_stream; there is no custom lexer) and PyMuPDF for inventory and rendering.

The pipeline runs in this order:

  1. preflight
  2. inventory
  3. analyze (vision)
  4. inject (content rewrite)
  5. structure (tree and ParentTree)
  6. post passes
  7. verify
  8. the cli.evaluate gate

PORT:

  • inject.py::Injector._rewrite is the proven wrap-untagged-content algorithm. It:

    • simulates the CTM (q/Q/cm), Tm/Tlm (BT/Tm/Td/TD/T*/’/“/TL) and the current font, including across nested forms;
    • strips existing marks and renumbers MCIDs per page;
    • snaps each show op’s device-space origin to an inventory line;
    • buffers text objects until ET, then uses flush_bt (one BDC outside BT when a single element owns the whole object) or flush_bt_mixed (marks inside BT/ET, merging consecutive same-element shows into one MCID);
    • wraps paths, sh and inline images as /Artifact, and turns image Do into a Figure MCID or an Artifact;
    • artifacts whitespace-only text, decided font-first through ToUnicode (_shows_only_whitespace).

    It does not split a Tj/TJ at word boundaries. Tag-studio’s word-level split is new work.

  • promote.py: classify_form (empty / artifact / elem / mixed), split_form / build_slices (slices a mixed Form XObject at q..Q boundaries), expand_shared_forms (clones shared forms), and gs_is_risky. Forms are promoted instead of using /Stm MCRs because Acrobat and GrackleGo mishandle /Stm. Keep that rule.

  • structure.py: StructureContext and the ParentTree section of build_structure. Keep these rules:

    • one key per page, shared by the page stream and every fallback form on it;
    • MCIDs are page-global, so keys never collide (#2455/#2456);
    • the page always gets /StructParents, because pdf.js only reads that key;
    • OBJR keys are appended after stream keys so the number tree stays sorted.

    Multi-page accumulation fixes #2155 (the multi-page tree rebuild).

  • decorative.py: _rewrite_wrapper, _promote, _demote. These are the only existing artifact ⇄ un-artifact ops. They swap the single BDC/BMC operator at an index and patch the ParentTree slot: null on demote, max+1 MCID with null padding on promote. This is the template for Gevell Tags’ artifact / unartifact ops.

  • structedit.py:

    • set_role and _heading_outline_ok, the whole-tree PDF/UA 7.4.2 rule against skipped heading levels;
    • restructure_table, _validate_restructure_grid (exact tiling, spans in bounds, each source cell used once, valid Scope) and _apply_cell_attrs;
    • _has_marked_content, used to refuse dropping a cell that has content;
    • reorder_children;
    • label decoding: _parse_tounicode, _font_cmap, _decode_glyphs, _join_mark_texts.

    Keep the #2485 codespace fix: simple fonts use 1-byte codes even when they declare a 2-byte codespace.

  • table_headers.py: compute_headers, apply_headers_ids, build_id_tree. These build /ID, /Headers and the IDTree for complex tables.

  • textdecode.py and geometry.Mat.

  • altedit.set_alt, together with the meaningful-alt floor (#2836).

REUSE-AS-IS (server side):

  • verify.pixel_diff. It renders both files with PyMuPDF at 150 dpi, uses a per-channel tolerance of 2, and gates at PIXEL_THRESHOLD = 99.95% per page (cli.py:97). Use the same numbers for the Rust gate so results stay comparable.
  • verapdf_check and qpdf_check.
  • The AI pipeline: analyze, textract_ocr, ocr_tag + glyphless_font, fontsub, tounicode.add_missing_tounicode, cidgidmap, and the links describe-fetch.

REFERENCE:

  • formfields.py, links.py, otherannots.py (OBJR creation, Form/Link/Annot nesting, _interleave_annotations);
  • table_semantics.py, cell_grid.py, table_grid.py (see the span-counting bug in tag-tree-quality.md);
  • source_table_prior.py, which is the “narrowest thing, never downgrade, fail closed” pattern;
  • heading_shape.py, heading_rank.py, repeat_text.py, list_labels.py, formula.py, outlines.py, recolor.py;
  • sourceview.py and sourcediff.py.

DISCARD:

  • deploy/*, the positional-identity op transport (#2378), full-file rewrites on every edit, checkpoint_file, and devcost.

tools/tag-lint: PORT

This is a rules-as-data linter (#2524, phase 1) with 10 rules:

  • list-l-direct-child
  • li-missing-lbl
  • lbody-bullet-in-text
  • figure-missing-alt
  • empty-tag
  • whitespace-only-tag
  • heading-skip
  • duplicate-adjacent-tag
  • decorative-path-in-text
  • redundant-wrapper

Port these pieces:

  • walk.py: the MCID-level walk that recurses into Form XObjects the way Acrobat does, with per-MCID glyph and path counts.
  • The Finding shape: rule_id, severity, matterhorn, wcag, path.
  • engine.py: runs each rule in isolation, so one broken rule cannot take down the lint.

Fix the Matterhorn IDs while porting. empty-tag is labelled 01-004, which is actually “tagged content inside an artifact”, and 01-005 is a loose fit. Nothing else in the repo consumes tag-lint yet.

tools/pdfpolish: PORT the repairs as quick-fix ops

  • repairs/structrepair.py: RoleMap (Matterhorn 02), ParentTree, ParentTreeNextKey, and MCID orphan reconciliation.
  • repairs/tagtree.py: removes empty elements and wrapper elements while keeping the ParentTree slots consistent.
  • repairs/inlinewrap.py: wraps ILSEs (Link, Figure, Span, Quote) that sit directly under a grouping root. It re-parents the element in place and changes only its /P, which means no ParentTree edit is needed. This is a clean worked example of a structure op that needs no content change.
  • repairs/docmeta.py: MarkInfo, Title, DisplayDocTitle, /Lang, /Tabs /S.
  • repairs/ported.py: OC config /Name and CIDToGIDMap.

verifygate.py is a REFERENCE. It has two pixel modes: strict identity, and “changed pixels only inside the targeted bboxes”. The second is what recolor-type edits will need later.

The files in output/ and reports/ include real university documents. Check their provenance before using any of them as fixtures (see “Never commit a customer PDF” in tag-tree-quality.md).

Question 1: Do we already have a PDF/UA-1 / Matterhorn checker?

We have no native checker. We do have a real veraPDF deployment.

What exists:

  • services/verapdf/server.py is a Flask wrapper around verapdf/cli, called as POST /validate?flavour=ua1. It runs as a sidecar on the EC2 anchor (VERAPDF_URL=http://verapdf:5002).
  • workers/api/src/services/verapdf-client.ts parses the veraPDF JSON into {clause, testNumber, passed, failedChecks}.
  • pdf-ua-claim-gate.ts strips pdfuaid:part unless veraPDF confirms the file passes (fail-closed).
  • flyer-tagger bundles the veraPDF jars and calls them from verify.verapdf_check. pdfpolish and the census tooling call veraPDF too.
  • tools/pdf-inventory/src/census/ua1_rules_catalog.json lists all 106 veraPDF UA-1 rules. clause-class-map.json assigns each one to class A (structural, 101) or class B (needs authored text, 5).
  • docs/admin/products/pdf/matterhorn-automation-plan.md maps all 31 Matterhorn checkpoints and 136 failure conditions (87 machine, 47 human, 2 with no test) to veraPDF clauses.

What the home-grown checks cover. They are quality and heuristic layers: tag-lint, tree_findings, the apps/tag-editor audit rules, pdf-manual-risks-structure, and the regex scorer. Between them they touch about 10 checkpoints (01, 02, 06, 07, 11, 13, 14, 15, 16, 28, part of 09) and none of them exhaustively. Nothing native covers:

  • checkpoint 10/31 (fonts, ToUnicode and embedding: the largest deterministic cluster);
  • checkpoints 19, 20, 21, 25, 26 and 30;
  • the 7.2 nesting-legality rules, which are 41 of the 106 veraPDF rules.

Recommendation: use both a ported checker and veraPDF, each at its own layer.

  1. Live validator: port into Rust, running against the in-memory model. Use the veraPDF UA-1 clause IDs from ua1_rules_catalog.json as the rule ID space, so live findings and veraPDF findings line up. Start with the rules that are cheap on a tree:

    • 7.1: standard types or RoleMap resolution with cycle check, content not tagged, Suspects, MarkInfo, DisplayDocTitle, Lang;
    • 7.2: the nesting table (the biggest win);
    • 7.3: Figure Alt;
    • 7.4: headings;
    • 7.5: tables;
    • 7.9: notes;
    • 7.18: annotations, /Tabs, /StructParent;
    • 5: pdfuaid in the XMP.

    The “no false passes” requirement in #3866 means the live validator must label each rule as checked / not checked by this engine. It must never show green on the strength of a rule it did not run.

  2. Authoritative verdict: keep calling veraPDF server-side on save and export, and route the result through pdf-ua-claim-gate.ts. Leave the byte-level rules (7.21 fonts, CMaps, embedding) to veraPDF. Do not reimplement veraPDF in WASM, and do not ship the regex scorer as a validator.

Question 2: What content-stream rewriting has proved pixel-identical in production?

  1. flyer-tagger inject.py + promote.py. This is production preserve-lane tagging on Lambda and the host lane. It only ever wraps painting ops: it inserts BDC/BMC/EMC, strips old marks, and slices or clones Form XObjects. Every output is gated by pixel_diff (150 dpi, tolerance 2, ≥ 99.95% per page), qpdf and veraPDF. About 27 test files assert pixel_diff, including test_beacom_e2e, test_multipage, test_structedit and test_decorative.
  2. flyer-tagger decorative._rewrite_wrapper. This is the production artifact ⇄ Figure swap behind the /tags editor. It replaces one operator and is pixel-safe by construction. It is re-verified after every edit with orphaned_mcids == 0 plus qpdf.
  3. apps/tag-editor inject/content-marker.ts. It is insertion-only, and verifies by stripping the insertions and comparing with the original byte for byte. That works as a proof without rendering, but it only applies to fully unmarked files, and its live usage is small.
  4. packages/tag-editor-ui recolor and border_recolor. These change paint on purpose, so they are not pixel-identical. REFERENCE only.

Not proven anywhere: splitting a show op at word boundaries, which needs TJ-array splitting with a Td re-anchor and glyph-width advance. Also not proven: un-artifacting arbitrary text (anything beyond the image wrapper swap). Both are new engine work and must go through the render-diff gate from the start.

Question 3: Which fixtures and tests should become engine round-trip tests?

No-op round-trip (open, save with no edits, byte-prefix-identical, reparse, ParentTree consistent, render-identical):

  • docs/standards/*.pdf: 14 tagged spec PDFs. Several are Acrobat PDFMaker for Word exports (PDF-Declarations.pdf, PDF20_AN001-BPC.pdf), and Tagged-PDF-Best-Practice-Guide.pdf is Word + axesWord. These are the best real-world, redistributable set, including the array-valued /StructTreeRoot /K shape that burned #3472.
  • tools/pdf-inventory/src/census/fixtures/pdfs/: 6 files, including a PDFMaker form, a Publisher file and a scan.
  • tools/contrast-sweep/fixtures/{complex,form}.pdf.
  • TheAccessible-Contact-Form*.pdf in the repo root: tagged AcroForms, one of them UA-2.
  • workers/api/src/__tests__/fixtures/image-of-text/: 10 parser-robustness edge cases (degenerate CTM, unbalanced form stream) and link-annots/weasyprint-inline-formatted-links.pdf.
  • services/flyer-tagger/fixtures/: beacom-flyer.pdf (Canva) with beacom-analysis.json, which gives deterministic replay; beacom-flattened.pdf (OCR lane); and Mathematics Review.pdf (pdfTeX, formulas).
  • The R2 corpus: RCL2DEV:accessible-dev-assets/TestFiles (the target of the apps/tag-editor corpus test) and tag-corpus/docs/* once #3862 lands.

Op-level spec tests to port as Rust tests:

  • apps/tag-editor: roundtrip.test.ts (classic, xref-stream, ObjStm, hybrid, OBJR, MCR, RoleMap, IDTree), commands.test.ts, advanced.test.ts, reclaim.test.ts, inject.test.ts, the verifier tamper cases, and fixtures.ts / fixtures-advanced.ts, which are in-code PDF builders.
  • flyer-tagger: test_structedit, test_decorative (the demote → promote round trip), test_multipage, test_page_level_tagging, test_whitespace_ops, test_tounicode, test_textdecode, test_nested_paths, and the synthetic builders in tests/conftest.py (text_pdf, image_pdf, form_xobject_pdf).
  • tag-lint: the hand-built tree tests, as the validator spec.
  • pdfpolish: the structrepair, tagtree and inlinewrap tests, as quick-fix specs.
  • tools/tag-corpus/tests/pdfbuild.py (on the PR branch): synthetic PDFs built the way Word builds them.

Question 4: Which semantic tag-tree rules belong in the live validator?

These are the rules veraPDF cannot see, from #3856 (tools/tag-corpus lint.py / invariants.py), flyer_tagger/tree_findings.py, tools/tag-lint, apps/tag-editor rules.ts and pdf-manual-risks-structure.ts. They should run live against the in-memory model with their own ID prefix, as findings, never gate failures, following the tag-tree-quality.md rule that “a clean conformance gate is a floor, not a verdict”.

RuleSource(s)
no_lang (document or part)tag-corpus lint, tag-editor doc-lang
Figure or Formula without Alttag-corpus, tag-lint, tag-editor, formula.formula_alt_problem
Heading level skip, first heading not H1, h1_flood / multiple H1, empty headingtag-corpus, tag-lint, tag-editor
Heading that is really a paragraph (heading_shape decision table)tree_findings.headings_that_are_paragraphs
Running header, footer or page number tagged as content, or tagged as H/Title (margin_text_is_artifact, not_in_role)tag-corpus invariants, pdf-manual-risks-structure
mcid_referenced_twice / layered duplicate text (no_duplicate_text), duplicate-adjacent-tagtag-corpus, tag-lint
untagged_text / tree text coverage below floor (0.80), orphaned MCIDstag-corpus, tree_assess, verify.orphaned_mcids, struct/orphans.ts
undecodable_text (no usable ToUnicode)tag-corpus lint
empty-tag / whitespace-only tag / empty_elements (ignoring Word’s empty BDC EMC runs)tag-lint, tag-corpus
element_spans_columns (per-page gutters)tag-corpus invariants
Reading-order jumps at column boundariestag-corpus reading_order
List structure: L > LI > Lbl/LBody, missing Lbl, bullet glyph in LBody, adjacent lists that should merge, sublist beside its item rather than inside LBodytag-lint, tag-editor, tree_findings
Table: TR/TH/TD parentage, table_without_th, missing row headers (#3771), table_mostly_empty, empty_tabletag-editor, tag-corpus, tree_findings
Link with no content (OBJR only)tree_findings._check_empty_links, verify.link_coverage
Title diverges from covertree_findings.title_divergence
Inline element (Link, Figure, Span) directly under a grouping elementpdfpolish inlinewrap
Redundant wrapper, non-standard role without a RoleMap entrytag-lint, tag-editor

The work of reviewing the corpus specs belongs to #3856. Tag-studio should consume the same rule module, as #3856 plans: one rule set used for editor highlights, the server-side gate and the review-risk report.

Existing-editor pain points (do not repeat)

  1. Positional identity. The /tags editor addresses nodes by flat index or child path. Every edit means a refetch, collapse state is lost, and conflicts and table 404s follow a reshuffle. Tag-studio needs stable node IDs that survive edits and saves.
  2. A server round-trip and full rewrite on every edit. Each click triggers a Lambda download, a pikepdf edit, a full save, and a qpdf/veraPDF reverify. There is no undo on the server editors.
  3. A narrow op set. Re-roling is limited to H1–H6 and P. Reordering is refused for containers that hold content directly. There is no wrap, unwrap, split, merge, general attribute editing, or Lang/ActualText editing.
  4. Two tree views with separate state. The flat walk_elements view and the nested walk_tree view are deliberately kept apart “so they don’t fight”.
  5. Delete leaves content untagged, not artifacted, and Reclaim appends orphaned content at the end of the root, in the wrong reading order.
  6. Content-changing ops reload the session. In apps/tag-editor, inject and recolor invalidate the NodeIds held by undo, so they are refused while edits are unsaved. Content edits must be first-class ops in the log.
  7. Weak highlights. Canvas highlights come from heuristic PDF.js text geometry (height × 1.2). Multi-page elements get a null bbox. MCIDs inside Form XObjects are not highlighted.
  8. Performance. Every edit bumps a React version and re-renders the whole editor, and the tree is not virtualized.
  9. Flyer-only scope. The /tags and /workspace editors require a delivered flyer job, and generic upload is still open (#2377, #2379). The UI also exists as two divergent copies (apps/admin and packages).
  10. Validation split across four places (the editor rules, tag-lint, tree_findings, veraPDF), each with its own IDs, and none of them saying what it did not check.