Repository navigation
feat: run analysis on DFO's engine and serve cards and shares from dfo-apis - #173
Merged
Merged
Conversation
dfo_platform.caller.resolve_caller names each request's caller without an API key. A request vouched for by the website proxy (x-dfo-caller-principal) is named by its x-dfo-caller digest; any other is named by the X-Forwarded-For entry DFO_TRUSTED_PROXY_HOPS from the right, hashed with the website's preimage (IPv6 folded to its /64) so both routes share one namespace and no raw address is stored. datasets.caller_allowance (migration 0039) counts each caller's pins and shares per UTC day; a spent allowance answers 429 allowance_exhausted with Retry-After at the next midnight. The pin door now admits any caller: grant holders keep the global budget alone, and other callers spend their allowance inside an open-caller ceiling of 200 copies per 24 hours. spec-kit moves to 2765bc3, which registers the new endpoints and codes.
POST /api/datasets/analysis takes a recipe tree whose roots are Specs, exact versions, derived frames or refs, with verb arguments in the caller's words (roles such as origin, destination and place; `on` pairing data with other). The runner reads the roots with their renderings, settles the rolling period once, evaluates the tree with dfo_analysis_engine, digests the output and stores the derived frame and its recipe, answering a format 2 ref. A rerun stores nothing new, and `latest` and exact runs share one path. A root still building answers 202 with Retry-After. Runs have their own pool of 2, hold a materialise permit, and are bounded by nodes, root rows and join rows. POST /api/datasets/analysis/anchor returns the latest period for given roots and stores nothing.
GET /api/datasets/derived/{ops}/{ocv} answers a derived frame's
manifest: retention (pinned or held_until), the roots' merged citation,
the recipe, resolved periods, and left_out, every series dropped for
missing data at any step of the tree, naming the step, input side,
reason and rows. .../file serves its rows as csv, xlsx or parquet,
alone or bundled. The engine now reports each step's drops, and the
runner stores them in the recipe.
POST /api/datasets/analysis also takes a bare derived root, re-running
its stored recipe, so fetching a derived ref with latest=True keeps the
same ops_ and ocv_ when no root moved.
datasets-maintenance diff-recipes re-runs every stored recipe on its
own root versions without writing anything and compares each output
frame's cv_ and output digest with the stored frame's, reporting equal,
differs, roots unreadable and frame unreadable. record-recipe-golden
records recipes and their frames for a test that the runner reproduces
each one; three synthetic cases stand in until dev recipes are
recorded.
`just local-stack up|seed|smoke|down|status` starts Postgres 18 and fake-gcs-server in docker, applies both migrators, and serves the Datasets and Presentation surfaces on 127.0.0.1:58083 with fixed local principals and caller settings. Upstream sources are fixture doubles, so nothing reaches GCP, Gemini or Data Commons. The seed mints four readings through the real mint door (German ODA to Kenya, Ghana and Nigeria; ODA to Ethiopia by donor; Kenya population; Kenya GNI), idempotently, and the smoke script checks each one's versions, manifest, observations and file bytes over HTTP.
artifact_store.signing.sign_get_url returns the storage emulator's media link when STORAGE_EMULATOR_HOST is set, with no credential work, and signs exactly as before otherwise. dfo_apis.app exposes build_presentation_surface, which create_app and the local stack both use, so the local app patches nothing and imports no private name.
…endering Card revisions store the rendering of the frame they draw (component_revision.frame_rendering, migration 0011), read from the version's records on every load, and a render judges its target against that recorded rendering. Card reads and renders carry the frame's retention: pinned, or held_until counted from when DFO stored the frame. An expired frame's refusal names dfo.fetch(..., latest=True). Creating, revising and live-rendering a card pin nothing. presentation_store.pin_card_frames pins a revision's frame when a share first includes it, and pinning a derived frame also pins the root versions its recipe names.
POST /api/presentation/shares publishes cards as a share with an 8-character id and answers its url, embed_url, one data URL per slot and a shw_ write token. latest is required, the caller's daily share allowance is spent, and the cards' frames are pinned when a share first includes them. PUT replaces a live share's cards with the token, keeping each slot's URL by position; a snapshot cannot be replaced (share_not_replaceable). DELETE takes the share down with the token. GET reads a share publicly and never shows the token. The token is checked before the mode, and a share with no token refuses every token. Presentation migration 0012 adds share, share_revision, share_slot and served_frame.
The analysis runner named flow datasets' enrichment columns by role word (origin, destination) while stored identities key them by column (financingProvider), so every analysis over a flow dataset refused column_unknown. It now matches both. The analysis route answers every Datasets code at its datasets.analysis status, with no fallback, now that spec-kit 4bcb6dc maps them. The definition and refresh helpers the runner reads roots through are public (served_spec, serve_spec, answer_frame_refresh). The local stack's smoke now runs a quotient_of over seeded readings, reads the derived manifest and Parquet rows, re-runs the derived root with latest, draws a card on the result, and creates, replaces and deletes a live share with its write token.
GET /api/presentation/shares/{id}/slots/{n}/data serves a slot's rows
as JSON, csv or parquet, publicly and across origins. A snapshot slot
serves its card's stored frame; a live slot draws the card on the
newest data, through the refresh path for datasets and the runner for
derived frames, checking at most once every 15 minutes per slot and
stating current, stale or pending in the body and x-dfo-freshness. A
strong ETag answers If-None-Match with 304.
Each frame a slot serves is recorded, and GET .../history lists them
newest first with their retention and whether each is still fetchable.
POST .../snapshot with the write token turns a live share into a
snapshot in place, pinning what each slot serves. Create takes a kind
(card, canvas, search, conversation) and a website-owned payload.
datasets-maintenance import-shares reads the website's share export
idempotently, keeps each id, and writes a fresh write token for every
signed-in owner's share to a 0600 file the website reads.
A card on an ops_ frame cited "Unattributed source": the frame store cited a derived frame from its rows' provenance refs, which a quotient of two sources leaves null, and a null ref mapped straight to the unattributed source. The frame store now cites a derived frame from its recipe's roots through datasets_api.derived.recipe_citation, the function the derived manifest uses, and a row with no ref stands for every source the manifest cites. A frame cached before its recipe was written is cited again once the recipe exists.
… rules on replace A live slot whose newest frame is still building is checked again after the retry time the refresh path or runner gave, clamped to 1 to 900 seconds, so a finished rebuild shows within that time; current and stale slots keep the 15-minute window. The slot check stores its next check time (migration 0014). Replacing a share applies the per-kind card rule create uses, so a live search share takes no cards. A snapshot share, conversations included, refuses share_not_replaceable before its cards are read.
The derived file route serves a bare Parquet request as the stored canonical frame's bytes, so the body hashes to the ref's artifact digest and clients can check and cache it; csv, xlsx and bundles still render. Runner roots take the groupby a fetched frame was minted with (a StatVar sum or a breakdown) beside without_party_totals and columns. The groupby is part of the root's rendering, so it enters the recipe's normal form, the output digest and the manifest's roots, and the root reads through the same head rendering a definition read uses. recorded_dataset_root is the one reader from a mint record to a root, shared by the runner, recipe replay and the golden.
…nown A card on a derived frame published without steps was refused schema_invalid: a row with no provenance ref took the id of the first cited source, while the card description listed the computed version's inputs, whose ids are null for such a publish. with_card_description now gives a null row a description only when the record knows an input id, and then the row and the description share it; otherwise the card carries no description and its provenance lists the roots' sources. The analysis door builds every refusal through classify_analysis_exception, and the code sweep lists its two engine code passthroughs. The local-stack smoke checks that a card on a derived frame cites the derived manifest's sources in order.
…tryable
GET /api/presentation/shares/{id}/slots/{n} answers the slot's card in
the components read shape, drawn on the frame the slot serves, with the
same freshness, ETag and CORS as the slot data route; both routes take
their frame from serve_on_slot_frame, so they never disagree. A site
can draw a live share without the render principal.
Share create takes an optional client-minted shw_ write token: a retry
with the same token and body answers the first share, and the same
token with another body is refused (migration 0015). Card create
replies carry retention as reads do, and a frame with no version record
states null retention. binding_invalid and constraint_violation answer
retryable: false, since their fix is a corrected request. A share
payload may be up to 10 MiB, on create and on import.
…es its own A derived frame reached clients as the canonical frame, whose unit is a tagged struct, while a dataset version's file carries unit as text. A published derived frame now also stores its bare frame-layout Parquet file through render_and_store, the pipeline dataset files use, and the ref's artifact digest and the publish response name that file. The derived file route serves it, rendering it again from the canonical frame if its bytes are gone, so both kinds of frame read the same way. The canonical frame stays stored for cards, replay and pins.
…hment and site The derived frame file rendered with an optional enrichment and a fixed placeholder site and time. The analysis stores now carry the request's enrichment and site, the publish path passes them and a real prepared time to render_and_store, and render_and_store requires its enrichment again. A bare frame file reads none of them, so its digest is the same whenever the frame is published. A missing-field schema_invalid takes its retryability from retryable_for, like every other refusal, and answers false.
tests/e2e/test_dashboard_journey.py runs one client's path through both surfaces sharing one Datasets cell: a compare over a summed root and a second reading answers a format 2 ref whose file hashes to its digest; a card cites the roots' sources; a live share serves the derived frame with ETags, follows new data after the freshness window, and lists both frames in its history; replace, a wrong token, snapshot conversion and its pins, a retried create, unshare, and an open caller's pins up to the allowance each answer as a client expects. A strict xfail reproduces a derived frame over a summed root that stops following new data. tests/live_refresh/composed_app.py mounts the share routes and the shared cell as the app does, and conformance clause C12 states the pin rule and names the test.
A derived frame over a root read with a groupby sum stopped following new data when, at analysis time, the sum equalled the plain frame: the latest re-run refreshed the root by version alone, and a version that links both renderings is ambiguous once new data separates them, so it refused rendering_not_recorded and a live share served its old frame as stale. LiveRootSource now serves a root under latest through the same path as a fresh Spec root, with the rendering the recipe recorded, so a root is always read by version and rendering.
…wnloads spec-kit 5a1959de maps the share routes' refusals: an oversize share payload answers 413 payload_too_large before any allowance is spent, an I/O failure on a share route answers retryable 503 service_unavailable, and pin_not_permitted is retired with the PinNotPermitted refusal. The presentation surface classifies each route by its own door. A card's select lists are a set: canonical_select sorts them and drops repeats before an intent is stored, so the same intent draws the same table whatever order a client sends. A derived frame's card takes its default title from the frame's recipe, naming each composed variable from its roots' labels. The local stack serves signed download links on the API origin and answers the graph node reads card citations make, so clients need no link rewriting and card creates no longer wait on a refused read.
… over a population The analysis route published derived frames without steps, so their records named no inputs and their cards were titled by ops_ id. Each verb call now builds its step with steps.build_step (call_steps.py), and the runner publishes the steps with a retrieve per root, so a derived card reads like one the MCP published. A derived frame's party and type columns stay out of card series labels when their role column is present. A request may carry anchor_roots, the roots /analysis/anchor was asked about, so a rolling call over one side of a paired population resolves its latest period over the whole population. They enter the recipe's normal form and replay key. A token holder's repeat DELETE of a share answers 204.
GET /api/presentation/shares/{id}/slots/{n}/data takes the formats and
bundle option the components data route takes, through the same
export_component_data call on the frame the slot serves, so a live
card's bundle and spreadsheet downloads follow new data. A bundle of
json is refused.
An analysis step list over the byte bound sheds its coverage members,
largest first, until it fits (call_steps.shed_coverage), and the frame
is published with those steps.
Analysis sums the roots' recorded rows and refuses past the root-row ceiling before reading a frame, and refuses an output past the publish ceiling before storing it. Columns are put in one order in mint_options, so equal requests give one normal form; a stored pair whose recipe or bytes differ answers 500 internal_error and logs both identities. The latest period comes from the engine's latest_period_of. A request whose caller-principal header does not match DFO_CALLER_PRINCIPAL is refused 503, where it fell back to the address and pooled every website user under one allowance. The pin budgets and the caller's allowance are spent together in one transaction per UTC day (migration 0040), so concurrent pins cannot pass a ceiling, and Retry-After counts to the next UTC midnight.
A replace refuses under the row lock when the share became a snapshot or its revision moved. A slot check takes one claim, the first check included, and the slot keeps its last frame, freshness and check time while the claim runs; a dead claimer's claim lapses after twice the components timeout (migration 0016). Create, replace, snapshot and a replayed create record each slot's frame, so history lists a new share at once. import-shares pins every imported card's and slot's frame as create and snapshot do, and writes and fsyncs each token line before the share commits. A row with no provenance takes a derived frame's cited source on ops_ frames only; a dataset version keeps "Unattributed source". The shares door's public GET routes answer a cross-origin preflight with If-None-Match and keep their ETag and freshness headers through the platform CORS layer. Unsigned emulator links need DFO_LOCAL_STACK=1, which the local stack sets.
POST /api/datasets/analysis keys each request before reading a frame (the working tree, the resolved root records, latest and the execution identity) and stores its 200 body in datasets.analysis_answer (migration 0041). A request whose answer is stored, with its frame file still served, answers at once; one whose key is running answers 202 with Retry-After 5, so a client retrying after a 504 joins the one run and then reads the stored answer. The shares door waits up to 5 s for a free thread before refusing 503, so a share page with more slots than threads loads.
A share's history records an entry whenever what a slot shows changes, including a return to a frame it showed before, and an empty entry (no card, revision or frame, never fetchable) when a write leaves the slot without a card. A client asking what a slot showed after a replace emptied it reads that it showed nothing. Migration 0017 keys served_frame by an identity column and lets the card and frame columns be null together.
Creating or revising a card spends one unit of the caller's daily card allowance (DATASETS_CARD_DAILY_ALLOWANCE, default 500; migration 0042); a principal holding the publish grant spends none. A share create that fails after spending, at the pin or the store, gives its unit back. A live share holds at most 20 cards that draw a frame, checked on create and replace. A slot read takes the history lock only when what the slot shows changed, and a slot that becomes a note records that it shows no frame.
…30968 datasets-maintenance normalise-recipes sorts each stored recipe root's columns, as the analysis route now writes them, recomputes its normal-form hash and replay key, and moves the presentation revisions that carry the old hash, then the recipe row; --dry-run counts, a second run changes nothing, and a recipe it cannot reproduce or whose new key another row holds is reported and left. Run it after import-shares. A card create replayed with its write token spends no card unit. spec-kit is pinned to 3e30968, which maps allowance_exhausted on presentation writes.
Comments, docstrings, route descriptions and the READMEs for the analysis, pin, card and share routes and the local stack read as plain statements.
The output digest is out2: each row leads with its stat_var, so two frames pinning the same numbers to different variables differ, and the synthetic recipe goldens are recorded again. allowance_exhausted is served not retryable, with Retry-After naming the reset, on pins, shares and cards. spec-kit is pinned to 03d8f5a.
…ion vectors The analysis test doubles hand their fixed clock to the artifact store, inventory and recipe store, so retention tests hold on any date. A new test runs every operation-outputs vector in the client contract through the analysis runner and checks its rows or refusal code.
…igest The output digest names a variable that compare, sum_of, share_of or quotient_of writes by its verb and its inputs' variables, walked from the recipe tree, so a one-step and a filter-then-quotient_of with the same rows share a digest, an engine release or rebuild that moves no number keeps it, and swapped sides differ. A new analysis golden pins the filter-then-quotient case, and record_case keys root variables by root record. The composed app describes its cards as production does, and the dashboard journey asserts sources under description.sources. spec-kit is pinned to 17f0469.
… engine Analysis counts nodes as it walks, bounds anchor reads by root rows, refuses malformed ref members and rolling shapes, cuts the left-out list to the recipe's byte bound, and refuses a failed rebuild in place of waiting on it; GET on the analysis paths answers 405, and diff-recipes keys stored roots by record. Live share checks run analyses through the stored-answer path and share one running set with the analysis route, and refresh under the card's recorded rendering. Sharing a derived card checks its roots first, pins its frame, and pins each root still held; a frame with no recipe yet refuses retryable recipe_pending. Stated retention follows the frame's own version and revocation, and card reads and renders answer null when it cannot be read. A card records its rendering at create. A share create answers once its write commits, history records what each slot serves and pages by keyset (migration 0018), every GET on a public path carries Access-Control-Allow-Origin, a NaN payload is refused before any spend, and a conditional slot read answers 304 from its inputs. settle leaves caller keys alone and locks out concurrent pins, a pin racing to an existing copy refunds its units, pin budgets name their reset, and a derived frame's rendered file is linked to its version. call_verb refuses op_spec_invalid unless its inputs fill exactly the slots the op spec names, an `on` list holding anything but column names refuses op_spec_invalid, and a non-object rolling predicate refuses op_spec_invalid. A paired side with several rows per member and period refuses role_ambiguous naming that side and its entity binding. Any `on` makes rolling pairing explicit, so an anchor never reads flat populations the call does not use. A paired summary names the fields the single-series summary does, and a step that cannot be built is logged and left out of a computed result. Compare's data side lives in one table, and the engine tests run in every shard of the suite.
The local app builds one Datasets link and passes its cell and running analyses to both surfaces, as dfo_apis.app does, so the stack starts again and live share checks join analysis runs.
This was referenced Oct 29, 2026
Closed
jm-rivera
changed the base branch from
feat/dashboard-card-store
to
feat/dashboard-engine
October 29, 2026 02:29
minted_variable_names names a minted variable mv_ plus the first 32 hex
of SHA-256 over the RFC 8785 JSON of {verb, slot: sorted input names}.
A name document holds its inputs' names, so a name is 35 characters at
any depth; the JSON-text form doubled with each minting step.
_stored_file_digest reads through read_servable_version, so a cached
answer or a run that reuses a stored pair refuses snapshot_revoked (410)
when that output is revoked.
refund_together locks the allowance row before choosing between delete
and decrement, so a spend that commits between the two no longer leaves
the failed operation charged.
create_share, replace_share and snapshot_share take each card's rendering and write every slot's served_frame entry after the revision insert, under the share row lock. The revision's created_at is read from clock_timestamp() while that lock is held, and each entry takes it as first_served_at, so history lists writes in commit order and as_of between two replaces selects the first. A write whose history insert fails commits nothing and answers 503. The door's post-commit _record_shown and _record_slots, and the store's record_emptied, are removed. record_served takes the same lock and records only while the latest revision still shows that card in that slot, so a read that loaded an older revision adds nothing after a replace.
frame_ref_for puts "without_party_totals": true in the cv_ preimage when the cut is set and sorts groupby.by, and mint_options sorts by, so a request with the cut and one without, over a dataset the cut leaves unchanged, name two pairs and both answer 200; a replay of the cut recipe keeps the cut. data-mcp in dfo-ai computes the same pairs, and both repos pin cv_bb722d06259f357e6961a081f1c1f141 for one input. When a run's own pair holds no frame, _store answers the pair read_replay names for its recipe, so a frame stored under the earlier naming is served instead of refusing ReplayKeyHeld. The ref, the top tree node and any divergence report name the pair the frame is stored under.
copy_to_pinned (datasets_api.retention_admission) is the one path into pinned storage: the pin door's artifact, and each frame and derived root a share create, replace or snapshot conversion pins. It spends the global pin budget, the open-caller budget and the caller's pin allowance for each new physical copy, skips copies that already exist, and refunds only a copy that turned out deduplicated or whose source is gone. A copy that stands keeps its units when the share write fails. Share routes answer PinBudgetExhausted and OpenPinBudgetExhausted as 503 with Retry-After, and allowance_exhausted as 429. LiveFrameStore takes the ledger as a required argument; share_import passes None. DATASETS_PIN_DAILY_ALLOWANCE defaults to 200. datasets-maintenance take-down-share <share_id> takes a share down as the owner's delete does, for the shares the website records in its share_cleanup table; an id with no share up exits 0. normalise-recipes also sorts groupby.by. An imported share records each slot's history entry, with its card revision's rendering, in the import transaction.
b7a40ad names minted variables mv_ plus a fixed-size digest and admits a derived ref root that carries the groupby DFO read it with.
When an analysis run answers the pair its replay key names, every field of the reply (ref, tree, summary and both frame_content_version values) names that pair and describes the stored frame. If that pair's file is no longer servable, supersede_replay marks the old recipe superseded and the run publishes under its own pair; migration 0043 adds frame_recipe.superseded_at and keeps replay keys unique among recipes not superseded. record_served records only while the revision the read served is still the latest, so a read in flight during a replace or snapshot conversion adds nothing. The shares door passes its allowance ledger to the frame store it builds, and live.py builds none of its own. Pin defaults: 100 new copies per caller a day, 2,000 for open callers together and 2,500 in all, so one caller can spend at most 5% of the open budget. Docs and refusal text say share routes spend them too.
0cb55e3 adds a nested minted-name vector with name documents and records the grouped-root ref vectors from a dfo-apis run, with by sorted.
A share replace pins the frames it adds against its caller's pin allowance, so the smoke PUTs send X-Forwarded-For as the create does.
# Conflicts: # src/datasets_api/README.md # src/datasets_api/config.py
) * perf(datasets): send no request for members with no dispatch facet _plan_collection_chunks packed eight consecutive members and skipped a chunk only when none had a dispatch facet, so 753 of the 1,974 members the total-ODA mint fetched held no facet and rode for nothing. Idle members now plan into chunks of their own that take the skip path and send no request, sync_estimate counts only members with coverage rows under the bound roles with lanes per component, and the batch target rises from 8 to 16. Total ODA sends 77 requests where it sent 247, with the same content version, rows and artefacts. * fix(datasets): never plan fewer requests for more members stat_var_chunk_positions planned 3 requests for 5 members and 4 for 4, so when the election idled a covered member the estimate could fall a request short of dispatch. Each group now splits into min(lanes, n) consecutive runs whose sizes differ by at most one, so the count never falls as members are added and the estimate can only overshoot.
…rices (#191) * fix(search): let named values a family group lacks drop out of its collapse A named value no partition of a group carries no longer blocks the others, so commitments steers the collapse when the unit text also names RealValue. * fix(search): elect the partition carrying the most named values, unconvertible ones first A price basis can still be served by conversion after election and any other named value cannot, so the collapse ranks partitions by the values they carry that unit conversion cannot reach, then by those it can.
* fix(search): merge parts that bind the differing slot to a set _build_merged_slots reads a value as a set of one and merges the union, refusing code sets that share a code and sets on the where axis. * fix(search): state one provenance for a merged slot and keep a whole member set's ref A merged slot filter reads text_recognized when any member named the property in text, a value without a ref refuses the merge, and a merged set equal to one member's set keeps that member's set_ref.
# Conflicts: # packages/dfo-data-tools/pyproject.toml # pyproject.toml # uv.lock
An error occurred while trying to automatically change base from
feat/dashboard-engine
to
dev
October 29, 2026 22:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #175
Datasets runs analysis on DFO's own engine, and Presentation serves cards and shares that follow new data.
POST /api/datasets/analysistakes a recipe tree whose roots are Specs, exact versions, derived frames or refs, with arguments in role words. It evaluates the tree withdfo_analysis_engine, stores the derived frame and its recipe, and answers a format 2 ref. Requests are keyed before any read and their answers stored (datasets.analysis_answer), so a retry joins the running request. Runs are bounded by nodes as the tree is walked and by root rows before any read.GET /api/datasets/derived/{ops}/{ocv}answers a derived frame's manifest (retention, citation, recipe, periods, andleft_out, every series dropped for missing data), and.../fileserves its rows as the stored Parquet file or as csv, xlsx or a bundle.The output digest (
out2) names each row'sstat_var. A variable that compare, sum_of, share_of or quotient_of writes is namedmv_plus a digest of its verb and its inputs' names, fixed size at any depth (output_digest.minted_variable_names), so equal computations over equal variables share a digest across recipes, engine releases and rebuilds. A frame pair'scv_preimage includes the party-totals cut and sortsgroupby.by, as dfo-ai's data-mcp computes it, so a request with the cut and one without name two pairs. A run whose pair holds no frame answers the pair its replay key names, and supersedes that recipe when its file has aged out (frame_recipe.superseded_at). A cached answer or reused pair whose output is revoked refuses 410snapshot_revoked.dfo_platform.caller.resolve_callernames each caller without an API key, by the website's vouchedx-dfo-callerdigest or the trustedX-Forwarded-Forentry.datasets.caller_allowancecounts each caller's pins, shares and cards per UTC day. A spent allowance answers 429allowance_exhausted, not retryable, with Retry-After naming the reset, and a refund locks the counter row first. Every new copy into pinned storage (pin, share create, replace and snapshot conversion) goes throughretention_admission.copy_to_pinned. It spends the caller's pin allowance (default 100 a day), the open callers' budget (2,000) and the global budget (2,500), skips copies that exist, and refunds only a deduplicated copy or a missing source.Card revisions record the rendering of the frame they draw, and creating or rendering a card pins nothing. A share (
/api/presentation/shares) publishes cards with ashw_write token that replaces, snapshots or takes it down, and pins each frame on first inclusion. A derived card pins its frame and each root still held. Public slot routes serve each slot's card and rows with freshness, ETags and CORS, and a live slot checks for new data at most every 15 minutes through the analysis route's stored-answer path. History records what each slot serves, written in the transaction that writes each revision and dated in commit order, and pages newest first (limit,before,next).datasets-maintenance take-down-share <id>takes down, without its token, the shares the website records inshare_cleanup.just local-stack up|seed|smoke|downruns both surfaces on Postgres 18 and fake-gcs-server with fixture upstreams, for the SDK and website journeys.Deploying: apply Datasets migrations 0039 to 0043 and presentation migrations 0011 to 0018 before traffic; set
DFO_CALLER_HMAC_KEYandDFO_CALLER_PRINCIPALto the website'sCALLER_HMAC_KEYandDFO_CALLER_PRINCIPAL; rundatasets-maintenance import-shareswith the website's export, thendatasets-maintenance normalise-recipes; restart the presentation instances. The website (DevelopmentFinanceObservatory/website#77), the SDK and dfo-ai's runner go out after this deploys. Second of two stacked PRs, on #170.Left for follow-ups: a row count in the derived manifest (#176), several source refs per card series (#177), and reclaiming pinned copies nothing references (#183). Nothing deletes pinned storage today, so the daily budgets bound how fast it grows.
Testing: the CI jobs, with
pytest -n 8in five shards withDFO_SPEC_KIT_REQUIRED=1overtestsand the engine package after merging dev (42,201 and 1,625 passed), including every output-digest and operation-outputs vector in the client contract. 42 Datasets and 18 presentation migrations applied on Postgres 18, a re-run was a no-op, and the live store tests passed (91 and 20), including the refund race and history ordering under row locks. On the local stack the smoke passed 22 checks, the SDK journeys passed (Python 13, TypeScript 7, a grouped-root share in both, and the crossing test), the share journey through the website proxy passed 3 of 3, and the website's share migration round trip passed./code-review xhighran over every commit, and an outside review's findings are fixed here or listed above.