Gevell Tags document sync API and verification job
Issue #3969 (Phase 2 workstream H, epic #3866, tracking #3972). Decision record: engine-adr.md §9.
API contract: docs/api/tags-openapi.yaml.
The engine runs in the browser. The server stores four things and verifies saved versions:
- the base PDF;
- the append-only op log;
- a single-editor lease;
- saved versions.
Where the code is
| Piece | Path |
|---|---|
Route (Lambda only, /api/v1/tag-docs) | workers/api/src/routes/tag-docs.ts |
| Rules: ownership, conflicts, versions, cost | workers/api/src/services/tag-docs/service.ts |
| DynamoDB conditions | workers/api/src/services/tag-docs/store.ts |
| S3, presigned URLs | workers/api/src/services/tag-docs/blobs.ts |
| Config and allowlist | workers/api/src/services/tag-docs/config.ts |
| Verifier (SQS consumer, oracles) | workers/api/src/tags-verifier/ |
| Verifier image | services/tags-verifier/Dockerfile |
| Infra (off by default) | infra/cdk/lib/stacks/tags-stack.ts, tagsEnabled in infra/cdk/lib/env-config.ts |
| Cost op types | migration 20261001000001_339_cost_ledger_tags_optypes.sql |
routes/tags.ts is an unrelated, older feature: file labels at /api/tags. The document API therefore lives at /api/v1/tag-docs.
Storage layout
DynamoDB. These items go in the existing single-table MainTable (accessible-pdf-production-data). Ids are server-issued UUIDs.
| pk | sk | What |
|---|---|---|
DOC#<docId> | META | owner, title, status (awaiting_base/ready/deleting), baseSha256, headSeq, latestVersion, writeUnits (unbooked cost) |
DOC#<docId> | OP#<seq, 12 digits> | one op record, verbatim (or, when over 350 KB, an s3Key), plus the sha256 of its JSON |
DOC#<docId> | LEASE | holderUserId, holderSessionId, expiresAt (epoch ms) |
DOC#<docId> | VER#<n, 6 digits> | a version: upToSeq, sha256, size, s3VersionId pinned at commit, status, verification |
TAGUSER#<userId> | DOC#<docId> | owner index, used for listing (no GSI needed) |
Version 0 is the base PDF.
S3. The bucket is accessible-pdf-production-tags-docs-<account>. It is versioned and SSE-KMS with its own CMK (bucket keys on). The bucket policy denies any PUT that is not aws:kms with that key. Object layout under tags/docs/<docId>/:
base.pdfversions/<n>.pdfops/<seq12>-<opId>-<sha256 prefix>.json(offloaded op records; content-addressed, writtenIf-None-Match: *, so nothing can overwrite one)reports/<n>/verify.json
Rules the server enforces
These are DynamoDB conditions, so racing Lambdas cannot both win.
-
Append is one transaction with three parts:
- a lease check: the caller’s session holds a lease that has not expired;
- a META update, conditioned on
headSeq = baseSeqandstatus = ready; - one conditional put per op (
attribute_not_exists(sk)).
-
Append results:
- A stale or skipped
baseSeqgets409 SEQ_CONFLICTwith the currentheadSeq. - A retry of a batch that already landed (same opIds and the same payload hashes at the same seqs) gets
200 duplicate: true. A retry that changed any byte is409 SEQ_CONFLICT. - Before the transaction runs, every record’s
seqmust equal its position and itsbaseHashmust equal the document’s base.
- A stale or skipped
-
Lease:
- Acquiring is a conditional put. It succeeds only if there is no lease, the lease has expired (
expiresAt < now), or the caller’s own user and session already hold it. - Acquiring again is the heartbeat: the client sends it every 15 s, and the lease expires 45 s after the last one.
- A session whose lease was taken over can never append, not even at the right
baseSeq.
- Acquiring is a conditional put. It succeeds only if there is no lease, the lease has expired (
-
Versions:
latestVersionadvances under a condition on its old value, in one transaction with the lease check. Concurrent saves get409 VERSION_CONFLICT.- Commit HEADs the object and requires the exact size and SHA-256. It then pins the S3
VersionId, so downloads and the verifier read exactly the committed bytes even if the key is written again. - Commit is resumable: committing a base that is
storedwhile the document is stillawaiting_basefinishes opening it, and committing a version that isqueuedre-sends the verification message (the verifier drops duplicates by status).
-
Uploads are presigned for one key. The presign signs:
x-amz-checksum-sha256, so S3 rejects other bytes;If-None-Match: *, so an upload never overwrites;- the SSE-KMS headers.
URLs live for 5 minutes.
-
Ownership: every endpoint loads META and compares
ownerIdwith the JWT subject. Not yours and not found both return404. A malformeddocIdreturns 404 before any key is built. -
Delete:
- Set the document to
deleting, which blocks edits. - Book the unbilled write units.
- Purge every S3 object version under the prefix.
- Purge the items, META last.
A failure leaves the document
deleting, and the call can be retried. - Set the document to
Access flag (Phase 2)
TAGS_ALLOWLIST on the Lambda is a comma-separated list of Supabase user ids (auth.users.id). It is set from tagsAllowlist in infra/cdk/lib/env-config.ts.
- Unset or empty means nobody gets in:
403 TAGS_NOT_ENABLED. - Emails are not accepted.
- Missing storage env returns
503 TAGS_NOT_CONFIGURED.
Verification job
On commit of version n > 0, the API sends {type:'tags-verify', docId, version} to the tags-verify SQS queue. The tags-verifier container on the EC2 anchor then:
-
Loads META and VER#n. It drops the message if the version is already settled or the document is gone.
-
Sets the version to
verifyingand adds 1 toattempts. -
Reads base and version n at their pinned S3 versions, and the op records 1..upToSeq. The records must all be present and in order, and every offloaded payload must match its hash.
-
Runs the oracles:
Check Command pass fail could not verify replay tag-engine replay base --oplog log --out r.pdf --json, thensha256(r.pdf) == sha256(uploaded)exit 0 and equal bytes bytes differ; stored bytes ≠ committed hash; exit 3 (conflict or drift); exit 1 (save not clean) missing binary, exit 2, signal, timeout, bad JSON qpdf qpdf --check uploadedexit 0, or 3 (warnings) exit 2 anything else pixelDiff tag-engine diff base uploaded --jsonwith an explicitPDFIUM_LIB_PATH(150 dpi, tolerance 2, ≥ 99.95 % per page)exit 0 and report pass exit 1 and report fail; waivedwhen the version declaredpixelChangesReasonlib path unset or missing, exit 2, report and exit disagree ua1 verapdf --flavour ua1 --format jsoncompliant not compliant: reported, does not fail integrity missing binary, parse exception, other profile, exit ≠ 0/1, exit and report disagree -
Sets the verdict:
failedif replay, qpdf or pixelDiff failed;- otherwise
could_not_verifyif any check could not run (veraPDF included); - otherwise
passed.
The UA-1 result is stored as
ua1Compliantandua1FailedRules. A document that is still being remediated may fail UA-1, but a missing veraPDF is never a pass. -
Writes
reports/<n>/verify.json, sets the terminal status, and bookstags-verifycost.
Retries. An unexpected error (S3, DynamoDB, a missing op) leaves the message for SQS to redeliver. On the 3rd receive the verifier records could_not_verify, with the error in each check’s detail. The queue’s DLQ (maxReceiveCount 3) should only ever see crashes mid-job.
Oracle versions are pinned to the engine CI image (engine/ci/Dockerfile): qpdf 12.3.2, libpdfium from pypdfium2 5.13.0, veraPDF 1.30.2. The image build checks every oracle.
Cost tracking
| operation_type | Booked by | When | Estimate |
|---|---|---|---|
tags-storage | Lambda (recordCost) | each version commit, and delete | S3 PUT + KMS request + DynamoDB write units the log accrued since the last booking (META.writeUnits, decremented atomically after booking) |
tags-verify | Lambda (recordCost), from the durationMs the verifier stored | the first read of a finished version (GET doc or version), or delete; claimed once per version (verifyCostBooked) | durationMs always; dollars only when TAGS_VERIFY_HOURLY_USD is set on the Lambda (same convention as flyer-tagging-host-compute) |
The verifier books nothing itself, on purpose: it parses untrusted PDFs, so it holds no Supabase credentials. Write units are taken from META atomically (writeUnits >= :n condition), so racing commits or a retried delete never book them twice.
Storage-time cost (GB-month) is not booked per request. Read it from the AWS bill; the bucket carries the project=accessible-pdf tag.
Deploying (none of this has been applied)
Merging the PR changes nothing in AWS, because tagsEnabled is false everywhere: Deploy AWS synthesises identical stacks. Here is the order to switch it on.
-
Migration 339. Apply
supabase/migrations/20261001000001_339_cost_ledger_tags_optypes.sqlto prod through the Supabase MCP. It is additive and safe before the code. -
Infra.
- Set
tagsEnabled: trueandtagsAllowlist: ['<Larry's auth.users.id>']for production ininfra/cdk/lib/env-config.ts. - Run
cdk deploy AccessiblePdfProd-Tags AccessiblePdfProd-Api AccessiblePdfProd-Anchor, or merge and let Deploy AWS run--all. - Check the output for the new TagsStack (key, bucket, queue + DLQ), the
TAGS_*env on the API Lambda, and the grants on the anchor role.
- Set
-
Verifier image.
- Add
{ "name": "tags-verifier", "context": ".", "dockerfile": "services/tags-verifier/Dockerfile" }toinfra/cdk/anchor-images.json. - Add
engine/**andservices/tags-verifier/**to the path filters in.github/workflows/build-anchor-images.yml. - Deploy the Anchor stack first, which creates the ECR repo; the image build fails until the repo exists.
- Add
-
Anchor service. Add the service to
generate-compose.py/ the rootdocker-compose.ymland regenerate the compose file. The verifier parses untrusted PDFs, so it gets noenv_file(no anchor secrets, no Supabase key) and a locked-down container:tags-verifier:image: ${ANCHOR_REGISTRY}/accessible-pdf-production-anchor-tags-verifier:${IMAGE_TAG}init: true# Deliberately no env_file: only these non-secret values.environment:- TAGS_VERIFY_QUEUE_URL=<TagsVerifyQueueUrl output>- TAGS_DOCS_BUCKET=<TagsDocsBucketName output>- TAGS_DOCS_KMS_KEY_ARN=<TagsDocsKeyArn output>- DYNAMODB_TABLE=accessible-pdf-production-data- AWS_REGION=us-east-1read_only: truetmpfs: [/tmp:size=2g]cap_drop: [ALL]security_opt: [no-new-privileges:true]pids_limit: 256restart: unless-stoppeddeploy: { resources: { limits: { memory: 3g } } }logging: { driver: awslogs, options: { awslogs-region: us-east-1, awslogs-group: /gevell/anchor, awslogs-stream: tags-verifier } }Known residual risk: the container still reaches the anchor’s instance role through IMDS (hop limit 2), and that role is broad. A dedicated, narrow role for the verifier is a follow-up (see the LOW-findings issue).
Optionally set
TAGS_VERIFY_HOURLY_USD(amortised anchor $/h) on the API Lambda to pricetags-verifyrows. -
Smoke test as an allowlisted user:
- Create a document, PUT the base, and commit v0.
- Acquire the lease and append ops (produced by the engine).
- Create v1, PUT, commit, then poll
GET /versions/1until the status ispassed. - Also check that a non-allowlisted user gets 403.
Rollback: set tagsEnabled: false and redeploy. The API returns 503, the verifier container can be stopped, and the bucket and key are RETAIN.
Runbook
- Version stuck in
queued. Check the verifier’s logs (/gevell/anchor, streamtags-verifier) and queue depth. The client can re-commit only fromenqueue_failed, so a stuckqueuedversion needs the verifier running. - DLQ has messages. The verifier crashed mid-job three times. The version is left
verifying. Fix the cause, then redrive the DLQ: the job resumes fromverifying. - Every version is
could_not_verify. An oracle is missing or misconfigured in the image; see each check’sdetail.PDFIUM_LIB_PATHmust point at the bundledlibpdfium.so.
Testing
-
npx vitest run src/__tests__/services/tag-docs src/__tests__/routes/tag-docs.test.ts src/__tests__/tags-verifierinworkers/api. The DynamoDB fake evaluates the store’s real condition expressions, so removing a condition fails a test. -
Real oracles (opt-in; the workers/api CI job has no oracle toolchain):
Terminal window cargo build --release -p tag-engine-cli --manifest-path engine/Cargo.tomlcd workers/apiTAGS_VERIFIER_REAL_ORACLES=1 PDFIUM_LIB_PATH=<libpdfium file> \npx vitest run src/__tests__/tags-verifier/real-oracles.test.tsThis produces a save with
tag-engine applyand checks the following:- a genuine save passes;
- one changed byte fails replay;
- a dropped op fails;
- removing each of tag-engine, qpdf, PDFium and veraPDF gives
could_not_verify.