Skip to content

feat(ledger): extract claims from a lane that never becomes posts - #4140

Merged
idoshamun merged 4 commits into
mainfrom
feat/ledger-side-lane-api
Aug 23, 2026
Merged

feat(ledger): extract claims from a lane that never becomes posts#4140
idoshamun merged 4 commits into
mainfrom
feat/ledger-side-lane-api

Conversation

@idoshamun

@idoshamun idoshamun commented Aug 23, 2026

Copy link
Copy Markdown
Member

The claim ledger only ever saw content that reached the feed. That leaves out the sources that state a change first — vendor changelogs, release notes, security advisories — because they make poor feed content.

This adds the daily-api half of a second lane over yggdrasil.v1.ledger-document-published (schema #193). Yggdrasil fetches and cleans the document; daily-api owns the record and the extraction, because the specificity bar and the statement dedupe are DB-backed here. Bragi needs no change — ExtractClaimsRequest.post_id is correlation-only.

What's here

  • ledger_document holds the record, keyed by the document_id yggdrasil sends — the ledger mints no id of its own. Deliberately no source table: name and class are denormalised onto the document, so there is no registry to keep in sync with yggdrasil. extractedAt is a timestamp rather than "does any candidate exist", so a document that yields zero claims is not re-extracted, and re-billed, on every redelivery — which is what the post lane does today. contentHash is what reopens a rolling release-notes page that keeps one document while gaining entries.
  • Claims decouple from posts. claim_candidate and claim_evidence gain a nullable documentId, and claim_candidate.postId becomes nullable. No post id is synthesised for a document; the extraction target is a union, so neither write path can express a candidate claiming both lanes or neither.
  • canonicalDocumentUrl is added beside normalizeEvidenceUrl, not in place of it: url is the cross-lane identity key and a feed hands us the same document wearing ?utm_source=rss, www. or plain http — but the weaker rule's output is already persisted in claim_evidence.url.
  • The extraction core moves to src/common/claimExtraction.ts. The two lanes differ only in how they resolve content and which key they stamp, so the rules that decide what the ledger is worth exist once.
  • No change_signal gate on the new lane. The feed lane can afford one only because enrichment had already paid for the triage; this lane skips enrichment entirely. Intended, not an oversight.
  • Cross-lane collision: whichever lane reaches a url the other already extracted stands down, so one document is not paid for twice.
  • The hygiene date backfill learned to read a document's crawl date, so a claim cited only by an undated changelog is not left permanently undated.

Worth a second look

  • The url index is plain, not unique. With the id as the upsert target, a unique url would turn a re-poll of the same url under a different document id into a constraint violation raised inside the worker rather than an insert. The consequence is that one url can now hold more than one document row, and the statement dedupe is keyed on documentId — so if yggdrasil ever re-publishes a document under a fresh id, the same entries would be extracted twice with nothing to dedupe them. Nothing here guards against that, because the right answer depends on yggdrasil's id semantics: if ids are stable per document, this cannot happen; if they are per fetch, the contentHash reopen never fires either and the lane needs an intra-lane url guard instead.
  • The post-side unique index is left untouched. The document lane gets its own partial unique index rather than splitting IDX_claim_candidate_postId_statement_unique. A null never equals a null in a btree, so the existing index already binds only rows that have a postId — recreating it would mean reproducing the cutover timestamp baked into its predicate and rebuilding a large index for no behavioural change.
  • The document-side collision check matches post urls by equality, not by canonical form. An exact match would need an immutable canonicaliser in SQL and an expression index over the whole post table.
  • Rolling back drops document-backed candidates. down() deletes rows with no postId before restoring NOT NULL; there is no post for them to fall back to.

Notes

  • The new subscription is wired in .infra/common.ts; the topic itself still has to be created in the streams repo.
  • @dailydotdev/schema is bumped 0.3.17 → 0.3.18 for the generated LedgerDocumentPublishedMessage. Enum numbering differs from bragi's ContentFormat (ledger XML is 1, bragi's is 0), so the mapping is written out rather than cast.

pnpm run build and pnpm run lint pass; migration applies, rolls back and reapplies cleanly, and generating against it shows no drift.

The claim ledger only ever saw content that reached the feed, which left
out the sources that state changes first — vendor changelogs, release
notes, security advisories — because they make poor feed content.

Adds a second lane over `yggdrasil.v1.ledger-document-published`:
yggdrasil fetches and cleans the document, daily-api owns the record and
the extraction, because the specificity bar and the statement dedupe are
DB-backed here and must not be duplicated in Go.

- `ledger_document` holds the record. No source table: name and class are
  denormalised onto the document, so there is no registry to keep in sync
  with yggdrasil. `extractedAt` is a timestamp rather than an existence
  check, so a document that yields nothing is not re-extracted, and
  re-billed, on every redelivery.
- `claim_candidate` and `claim_evidence` gain a nullable `documentId`,
  `claim_candidate.postId` becomes nullable, and a CHECK holds the
  candidate to exactly one lane. No post id is synthesised for a
  document.
- `canonicalDocumentUrl` is added beside `normalizeEvidenceUrl` rather
  than replacing it: url is the cross-lane identity key and a feed hands
  us the same document wearing `?utm_source=rss`, but the weaker rule's
  output is already persisted in `claim_evidence.url`.
- The extraction core moves to `src/common/claimExtraction.ts`. The lanes
  differ only in how they resolve content and which key they stamp; the
  rules that decide what the ledger is worth now exist once.
- The lane has no `change_signal` gate. The feed lane can afford one
  because enrichment had already paid for the triage; this lane skips
  enrichment entirely.
- Whichever lane arrives second at a url the other already extracted
  stands down, so one document is not paid for twice.
@pulumi

pulumi Bot commented Aug 23, 2026

Copy link
Copy Markdown

🍹 The Update (preview) for dailydotdev/api/prod (at cd06370) was successful.

Resource Changes

    Name                                                    Type                                  Operation
~   vpc-native-update-trending-cron                         kubernetes:batch/v1:CronJob           update
~   vpc-native-update-achievement-rarity-cron               kubernetes:batch/v1:CronJob           update
~   vpc-native-private-deployment                           kubernetes:apps/v1:Deployment         update
~   vpc-native-hourly-notification-cron                     kubernetes:batch/v1:CronJob           update
~   vpc-native-user-profile-updated-sync-cron               kubernetes:batch/v1:CronJob           update
~   vpc-native-rotate-weekly-quests-cron                    kubernetes:batch/v1:CronJob           update
~   vpc-native-update-source-public-threshold-cron          kubernetes:batch/v1:CronJob           update
~   vpc-native-update-tags-str-cron                         kubernetes:batch/v1:CronJob           update
~   vpc-native-clean-zombie-images-cron                     kubernetes:batch/v1:CronJob           update
~   vpc-native-channel-digests-cron                         kubernetes:batch/v1:CronJob           update
~   vpc-native-clean-old-notifications-cron                 kubernetes:batch/v1:CronJob           update
~   vpc-native-sync-subscription-with-cio-cron              kubernetes:batch/v1:CronJob           update
~   vpc-native-clean-zombie-opportunities-cron              kubernetes:batch/v1:CronJob           update
~   vpc-native-refresh-tool-stack-stats-cron                kubernetes:batch/v1:CronJob           update
~   vpc-native-materialize-monthly-best-post-archives-cron  kubernetes:batch/v1:CronJob           update
~   vpc-native-user-profile-analytics-clickhouse-cron       kubernetes:batch/v1:CronJob           update
~   vpc-native-user-world-clickhouse-cron                   kubernetes:batch/v1:CronJob           update
~   vpc-native-expire-super-agent-trial-cron                kubernetes:batch/v1:CronJob           update
~   vpc-native-materialize-yearly-best-post-archives-cron   kubernetes:batch/v1:CronJob           update
~   vpc-native-interest-scheduled-run-cron                  kubernetes:batch/v1:CronJob           update
~   vpc-native-personalized-digest-cron                     kubernetes:batch/v1:CronJob           update
~   vpc-native-clean-expired-better-auth-sessions-cron      kubernetes:batch/v1:CronJob           update
~   vpc-native-calculate-top-readers-cron                   kubernetes:batch/v1:CronJob           update
~   vpc-native-clean-channel-highlights-cron                kubernetes:batch/v1:CronJob           update
-   vpc-native-api-db-migration-892ca99d                    kubernetes:batch/v1:Job               delete
~   vpc-native-ledger-corroboration-cron                    kubernetes:batch/v1:CronJob           update
~   vpc-native-deployment                                   kubernetes:apps/v1:Deployment         update
~   vpc-native-user-posts-analytics-refresh-cron            kubernetes:batch/v1:CronJob           update
~   vpc-native-ledger-hygiene-cron                          kubernetes:batch/v1:CronJob           update
+   api-sub-api.ledger-document-published-extract-claims    gcp:pubsub/subscription:Subscription  create
~   vpc-native-ws-deployment                                kubernetes:apps/v1:Deployment         update
~   vpc-native-post-analytics-achievements-cron             kubernetes:batch/v1:CronJob           update
~   vpc-native-channel-highlights-cron                      kubernetes:batch/v1:CronJob           update
~   vpc-native-update-views-cron                            kubernetes:batch/v1:CronJob           update
~   vpc-native-worker-job-deployment                        kubernetes:apps/v1:Deployment         update
~   vpc-native-daily-digest-cron                            kubernetes:batch/v1:CronJob           update
~   vpc-native-squad-posts-analytics-refresh-cron           kubernetes:batch/v1:CronJob           update
~   vpc-native-update-highlighted-views-cron                kubernetes:batch/v1:CronJob           update
~   vpc-native-update-current-streak-cron                   kubernetes:batch/v1:CronJob           update
~   vpc-native-bg-deployment                                kubernetes:apps/v1:Deployment         update
~   vpc-native-rotate-daily-quests-cron                     kubernetes:batch/v1:CronJob           update
... and 22 other changes

…ne check

Review feedback on #4140.

The document row no longer mints an id of its own: `LedgerDocument.id` IS
the `document_id` yggdrasil sends, and the upsert conflicts on it. That
forces the url index to become plain — with the id as the upsert target,
a re-poll of the same url under a different document id would stop being
an upsert and become a constraint violation raised inside the worker.
Uniqueness moves to the id; the url is still stored canonicalised and
still indexed, because the cross-lane collision check only asks whether
any extracted document holds it, which needs no uniqueness.

`CHK_claim_candidate_one_source` goes with it. The extraction target is
a union, so neither write path can express a candidate that claims both
lanes or neither — the constraint was defence in depth, not load
bearing. Nullable `postId`, both partial unique indexes and the foreign
keys stay.

The migration is amended rather than stacked on: it has not run anywhere
but a local database, and it should read as though the table was always
shaped this way.
…-api

# Conflicts:
#	src/common/claimLedger.ts
#	src/common/ledgerHygiene.ts
#	src/routes/private/ledger.ts
#	src/workers/extractClaims.ts
Comments only, no behaviour change. Cut the restatements, the incident
history and row counts, the playbook citations and the justifications for
roads not taken, and compressed what was left to a sentence or two.

What stays is the handful a reader would otherwise reintroduce a bug
without: why `publishedDate` goes to bragi empty rather than dated today,
why `canonicalDocumentUrl` sits beside `normalizeEvidenceUrl` instead of
replacing it, why the race guard reads the filed statements twice, and
why the document lane withholds its crawl date.
@idoshamun
idoshamun merged commit aa11d5f into main Aug 23, 2026
9 checks passed
@idoshamun
idoshamun deleted the feat/ledger-side-lane-api branch August 23, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant