Skip to content

release: website 5dee9d0 to production - #88

Merged
jm-rivera merged 15 commits into
mainfrom
release/2026-10-31
Nov 1, 2026
Merged

jm-rivera merged 15 commits into
mainfrom
release/2026-10-31

Conversation

@jm-rivera

@jm-rivera jm-rivera commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Releases website at 5dee9d0 to production, cut on release/2026-10-31: fifteen pull requests.

Before merging: the infra release has applied, scripts/handoff.sh dfo-prod website has set SHARE_MIGRATE_JOB_WEBSITE, and both apis releases have deployed: every share call goes to routes apis #170 and #195 add, and #100 reads the publisher codes apis #239 adds.

From the deploy until the migration finishes, old /s/<id> links answer 404. Straight after the deploy, run:

gcloud run jobs execute dfo-prod-website-share-migrate --project=prod-fin-observatory --region=europe-west1 --wait

Exit 0 means every share was imported or already there. Exit 1 logs one share <id>: <code> <detail> line per refused share, and a rerun skips imported shares. Then run normalise-recipes on dfo-prod-datasets-maintenance, first with --dry-run, and read its datasets_recipes_normalised line before the real run. Open a few old share links to check them.

The migration refuses a share for good when dfo-apis cannot pin one of its frames: placement_refused for a live card with no cmp_ ref, not_found for a card revision dfo-apis does not hold, and frame_unavailable for a frame past the 30-day working tier. Prod's first shares date from 2026-10-08, so their frames start to expire around 2026-11-07. Run the job straight after the deploy.

Rollback: the previous image runs on the migrated database, since migrations 0022 and 0023 only add. It serves /s/<id> from the local share table, which this release no longer writes, so after a rollback a share taken down since the deploy shows again and a share made since the deploy answers 404.

Testing: CI passed on each pull request into dev, and the dev deploy passed on 5dee9d0. The production deploy waits for approval.

* feat(proxy): forward cards and shares and name the caller to dfo-apis

The public /api/v1 proxy now forwards /presentation/components and
/presentation/shares, passing the write-token, ETag and CORS headers
the share data route needs. Analysis, anchor, pin and derived reads
already sit under the datasets surface.

Every forward to dfo-apis carries x-dfo-caller and
x-dfo-caller-principal (DFO_CALLER_PRINCIPAL), so dfo-apis can count
allowances per client. The caller digest now hashes the folded address
(IPv6 to its /64, v4-mapped to IPv4), the same preimage dfo-apis uses
for direct callers.

* feat(share): export shares for dfo-apis and keep users' write tokens

scripts/export-shares.ts --since writes every share as one JSONL line
in the format docs/share-export.md sets out: id, kind, mode with
today's null-mode defaults applied, owner, created_at, title, a slot
per referenced data card, and the website-owned payload that renders
what slots cannot (search queries and parts, conversations, inline
cards, canvas layout). It reads only, orders by created_at then id, and
exits non-zero naming each share it cannot place.

The share_token table (migration 0022) keeps a signed-in user's write
token for a share held in dfo-apis.

* feat(share): keep shares in dfo-apis and render them from its records

Creating, fixing and deleting a share goes through dfo-apis' share
routes with the caller named on every write. A signed-in owner's write
token is kept in share_token and sent on later writes; guests may make
live shares, which they cannot change afterwards. /s/<id> and
/embed/<id> render the dfo-apis record: card shares from their slots,
search, canvas and conversation shares from the stored payload.
sharePayload places cards in slots for new shares and the exporter
alike, and the exporter writes frame: null for a card whose frame the
site does not hold.

scripts/import-share-tokens.ts loads the token file dfo-apis' importer
writes, idempotently. The per-guest share allowance moves to dfo-apis,
so spendGuestShare is gone.

* feat(share): draw live cards from dfo-apis' slot card route

A live card on /s/<id> and /embed/<id> reads its slot's card from
dfo-apis, drawn on the frame the slot serves, so the site needs no
render principal; the refresh route is a GET that passes the ETag both
ways, so the browser revalidates instead of redrawing. A live share's
cards are checked before the share is created, so a failing card never
leaves a share behind. Share create sends a minted write token and
retries once with the same body, and a payload over dfo-apis' limit
answers 413 payload_too_large.

* feat(share): download a live card's data from its slot

A live card's downloads, bundles and spreadsheets included, come from
dfo-apis' slot data route in the format and bundle the reader asked
for, so a download holds the data the card shows. The browser's
refresh reads answer once, with no pending loop, and live shares need
no credential.

* fix(share): show a snapshot's recorded data and keep owners' tokens

A snapshot slot draws and downloads the frame its slot recorded at the
switch, through the same slot routes a live slot uses, so the page,
its downloads and dfo.fetch(share) agree; a card whose read fails is
fetched again by the browser and shows the retryable error state. A
stored write token is dropped only when dfo-apis says the share is
gone or the token is wrong. Every share call runs under a 35 s
deadline, a create whose token cannot be stored deletes the new
share, and switching a search or conversation share to a snapshot is
refused as before. The proxy refuses path segments that decode to
"." or ".." or hold a slash.

* fix(proxy): refuse dot segments on every forwarding route

The graph and search catch-alls and the component export route refuse
a path segment that decodes to "." or ".." or holds a slash, as the
datasets and presentation routes do. A test finds every forwarding
catch-all under /api/v1 and checks each carries the guard.

* test(share): round-trip legacy shares through export and import

scripts/share-migration-round-trip.ts seeds one share per legacy case,
exports them, imports them into a local dfo-apis and the tokens back,
renders /s and /embed for each, checks an owner can write with the
imported token and a guest's share has none, and checks a second run
of both imports changes nothing.

* fix(share): leave a held token's creation time alone on a re-put

Putting the token a share already holds for the same owner changes
nothing, so a second run of import-share-tokens leaves share_token as
it was.

* docs: tighten comments and share docs

Plain sentences in the share, proxy and studio comments and in
docs/share-export.md and docs/development.md, and one typo.

* fix(proxy): encode every forwarded segment and pass slot freshness

Every forwarding route builds its upstream path through
forwardablePath, which refuses dot segments and encodes each segment,
so a double-encoded ".." or a "?" or "#" segment stays inside the
surface it was sent to; the component read route takes the same
check, and a test finds every route under /api/v1 and
/api/presentation that forwards. Slot data read through the site
keeps dfo-apis' x-dfo-freshness and x-dfo-checked-at headers.

* fix(share): take a deleted account's shares down and keep refusals honest

Deleting an account takes each of the user's shares down in dfo-apis
with its stored token before the user row goes; a share already gone
counts as done, and any other failure keeps the account and the
remaining tokens. A create refusal dfo-apis marks permanent answers
share_refused with its code and no retry; a transient one keeps the
retry. A create whose token cannot be stored, because another user's
row holds the id, takes the new share down. Titles are cut by code
point, GET /api/share/<id> answers a problem document when dfo-apis
fails, and the share export reads shares and parts from one snapshot.

* fix(share): draw SDK shares, bound a page's wait, and read slots directly

A share made through the Presentation API with no website payload
draws one card per filled slot. A share page waits at most 4 s for
its slot cards and leaves the rest for the browser to fill. Card
refresh and download URLs carry the slot, so their routes read that
slot and never the whole share record; a card no slot draws, as on a
conversation share, downloads through the component export route. A
component read refused with anything but 404 is transient, so a busy
dfo-apis no longer withdraws a live link.

* fix(share): cite a snapshot card from the time its slot was served

A snapshot card's citation dates from when its slot first served the
frame it shows, read once from the share's history within the page
budget, and falls back to the share's creation time. The share dialog
shows a permanent refusal, or a share too large, without a retry. A
lost write token no longer blocks account deletion, a token the site
cannot keep answers a retryable 502, and the download-pin path
encodes its segments.

* test(share): compare round-trip tokens in byte order

The round trip orders share ids with collate "C", as the JavaScript sort it is compared with does, so ids of mixed case compare equal in either order.

* fix: read every page of share history before dating a slot

readShareHistory reads one 500-entry page and returns its next cursor,
and servedSince follows the cursor until every framed slot has its
entry or the history ends, within the share page's 4 s deadline. A
cursor that repeats the one just sent counts as an invalid reply.

A snapshot citation takes its date from the slot's served-since entry
alone. When that entry is not found, the citation prints "(snapshot)"
with no date; the share's creation date could name newer data.

* fix: fence account deletion against share creates and keep failed take-downs

takeDownUserShares commits user.deleting_at before it lists the user's
share tokens. putShareToken inserts through a select of the user row FOR
SHARE that requires the fence unset (or older than 15 minutes, since
Better Auth runs no hook after a failed user delete), so a share created
during deletion either lands before the list or is refused and taken
down by createOwnedShare's compensation. A failure that keeps the
account clears the fence.

A take-down that dfo-apis answers 204 or not_found is done. One refused
with write_token_* is written to the new share_cleanup table, which has
no foreign key to user, logged as an error, and deletion continues; an
operator removes those shares with datasets-maintenance
take-down-share. A failed compensation take-down is recorded there too.
Migration 0023 adds the table and the column.

* fix: hold each deletion attempt's own fence until its list is empty

fenceShareTokens returns the attempt's fence value; takeDownUserShares
refreshes that value before each take-down, re-lists tokens until none
remain, and on failure lifts only a fence that still holds its value, so
a failed attempt cannot open another attempt's fence. createOwnedShare
reads the fence first and refuses a deleting account with 403
account_deleting before any dfo-apis call. A failed share_cleanup write
logs share_cleanup_not_recorded and keeps token_not_kept.

The share_cleanup_recorded log line is the alert for shares an operator
takes down; deploy/README.md gives the drain steps. History reads 100
entries on the first page and stops on a repeated cursor. The token
import reports a deleting owner as ownerDeleting.
…86)

The share migration job reads the website's own share rows, places each
as the export did, keeps each owner's shw_ write token in share_token,
and calls dfo-apis' PUT /api/presentation/shares/{id}/import with only
the token's SHA-256. It holds the owner's user row FOR SHARE across the
call, so an account deletion either waits and takes the imported share
down or lands first and the job skips the share. The export and
token-import scripts are removed. apisFetch signs requests from a Cloud
Run job as well as a service, so the job calls dfo-apis with an ID token.
…owser tests in CI without Chrome (#87)

The root tsconfig included motion-kit, so a product change that broke motion-kit's types failed `next build` and with it the production image. motion-kit is now excluded there and checked as its own project, which `pnpm typecheck` runs after the app's. Its four Chrome-backed tests skipped when no browser started; under CI they now fail with the launch error, and locally they still skip with it.
…ards a canvas shows (#89)

* fix(share): give gallery cards their share slots and embed only the cards a canvas shows

The live gallery pages refreshed each card without `?slot=`, which the slot
refresh route answers 404, so every card failed once shares moved to dfo-apis.
`liveSlotsOf` derives each card's slot with `slotCanvas`, the function that
gave the prod share its slots.

`/embed/<id>` drew every card not removed, including older-turn cards the
canvas hides. A live share holds those with no slot, so a live embed showed
them as failed and a snapshot embed crashed on them. The embed now lists
`canvasWidgetsOf`, as `/s/<id>` does.

* test(share): drop slotCards rows that pass without the embed fix
@jm-rivera jm-rivera changed the title release: website 5b8ec19 to production release: website b91925c to production Oct 31, 2026
…og (#90)

* feat(data): tidy the unit picker, About this answer and download dialog

The unit picker drops the standing euro preset, keeps the currency of
any country the question names, and puts a "Search N currencies" field
first (lib/search/unitPicker.ts, UnitPicker.tsx).

About this answer shows each fact once. A fact the search filled in
carries its reason and a Change control that opens the same menu the
sentence uses (changeTarget in lib/search/refineMenu.ts). Breakdowns
list their categories without a variable count, the publisher's own
indicator code sits on the Measure row from stat_vars.source_codes,
the source appears once, and the citation says "processed by" only
when a conversion was applied (lib/search/view.ts, WhyPanel.tsx).

The download dialog has one switch for the README, sources and
citation files, and folds extra columns under plain names with
examples from the answer and blank counts in words. Extra columns the
long file already writes are hidden by the API's in_long_layout flag,
with the long_layout_defaults comparison as the fallback until the API
serves it (lib/search/downloadOptions.ts).

* fix(data): route Change to Refine for an open recipient role

Refine keyed an open role by the role name (`destination:`) while the
About rows and the wire use `roleRef` (`where:`), so Change found no
Refine entry for an open recipient. Refine now keys roles by `roleRef`.
…91)

* feat(studio): credit sources on cards and redraw the More Info panel

The canvas title runs to the card filters and shows in full on hover
(WorkspaceToolbar.tsx, clipTitle removed). The card foot credits its
sources as "Source: X +N more" with a small More Info mark, drops the
indicator count, and credits a computed card's input publishers
(sourceLine in lib/chat/widgets.ts).

More Info shows the calculation first for quotient and share cards,
lists sources publisher first, offers short and reference citations,
and mints a public snapshot link for the citation; POST /api/share
returns the snapshot's servedAt. How this card was made is a step
rail (descriptionBlocks.ts stepRows). Cards drag from the head and
frame after a 4px move, so content clicks and selects text, while
touch and read-only canvases still pan (canvas/cardPress.ts).

* fix(studio): keep one citation link request per card version

The pending request lived in CardInfoPanel, so reopening More Info
re-enabled the button and a second click minted a second public
snapshot. Studio now holds the request per `citeLinkKey`
(`citeLinkOnce`) and reuses it until it settles.
)

* feat(studio): group answer cards in the chat and focus chart hovers

Several cards from one answer collapse into one "N cards" row that
expands to one-line rows, with Remove on hover or focus and a Remove
all menu (WidgetChips.tsx); the approved plan pill takes one line.

Chart hovers name only the nearest series with its legend name, fade
the others and the other legend entries, and list every series while
Shift is held (chartSetup.ts, lib/studio/chartHover.ts). Every palette
keeps its first eight colours and adds two, so ten series stay apart
(theme.ts). Table cards move constant columns and the text shared by
variable names into a caption, shorten only the variable column, and
sort by header with numbers read back from their display form
(lib/studio/tableView.ts).

* fix(studio): drop a revised table's stale names column

`redraw` spread the held widget under the new cells, so a revision
with no `stat_var` column kept the old `namesColumn` and shortened
names such as South Sudan to Sudan.
* feat(studio): show each turn's work in the Activity tab

The Activity tab showed failures and folded every step that worked
behind "Show N completed steps", so a turn with no errors told the
reader nothing. Each turn now opens with its question and an outcome
line, then lists the plan and the steps that ran a tool on the step
rail More Info uses, with each card a step made as a chip that brings
it into view. A retry that worked folds into its step as "Worked on
try N"; a failure that stands reads "Not done" with its full sentence.

`turnView` in src/lib/studio/activityView.ts builds the view, and
useChat stamps `approvedAt` on the turn when a plan is approved, so
the plan row sits among the steps.

* fix(studio): order Activity retries and the plan by arrival

Browser receive times can tie when a failed try and its retry arrive
in one chunk, so the retry and plan placement rules misread them.
`recordStep` and `recordApproval` in activity.ts number each turn's
events as they arrive (`startOrder`, `settleOrder`, `approvedOrder`),
and `turnView` folds a retry or places the plan by that order, with
the clock rule kept for older saves. A saved plan with no steps list
counts none, so reopening it no longer throws.
A Change in "About this answer" opens the menu of a headline token or
of the Refine button. Once the reader has scrolled down into the panel
that trigger sits above the viewport, and Radix drew the menu clipped
off the top of the page. revealThenOpen in src/lib/search/menuReveal.ts
scrolls the trigger under the site header first and opens the menu once
the scroll settles, unless another Change, a new answer, or the reader
scrolling takes over.
A heading menu reported a ref as one it changes even when its only
option was the current value, so "About this answer" offered Change on
a lone source and opened a menu with nothing else to pick. The source
menus now report their ref through sourceMenuRefs, which applies the
slotOffersChoice rule Refine already used, and the shared entity menu
through sharedEntityMenuRefs, which reports its ref only when it lists
another option or a truncated list. The menus still open, so a lone
source keeps its About block.
)

* fix(studio): open only the latest Activity turn and drop "not done"

The Activity tab flagged "N steps not done" in red on turns that
answered, where the agent had recovered from the errors another way.
A step that stands failed in a turn that answered now reads a grey
"Hit an error" with its cause. A turn ends marked failed only when its
stream failed before the reply had any text (endTurnActivity's
`answered`, read by useChat's hasAnswer), and only that turn shows a
red "No answer"; a turn the user stops is never failed. Only the latest
turn opens by default, each outcome part keeps to one line, and the
Activity scroll no longer stacks the panel padding on its fade padding
above the first question.

* fix(studio): count a card as an answer when a turn fails

A turn whose stream broke after it made a card, before any reply
text, read "No answer" though the canvas held its card.
endTurnActivity marks a turn failed only when it has neither reply
text nor a card.
…t Data (#97)

* fix(studio,data): repair the release review findings in Studio and Get Data

Number each chat message's turn from the end of the transcript so
forked shares pair replies with their own cards and plans. Keep the
"Computed by" credit in More Info, describe each dividing step from its
own inputs, settle a failed cite link and log its cause. Let loading
cards and text-card gaps drag. Read the members list while membership
is unsettled, scroll a heading token clear of the header before its
menu opens, and build the download dialog's in-file note from the same
flags as its extra columns, with kinds per role.

* fix(data): leave the Change menu scroll to #96

#96 scrolls a Change's trigger into view before its menu opens, for
heading tokens and the Refine button alike, so this branch keeps
SlotMenu, the .slot scroll margin and the header height as on dev.

* fix(studio,data): classify in-file columns once and settle cite links in the helper

The download dialog's extras list and in-file note read one classifier,
longLayoutSuffix, so a role's kind columns are never both claimed in
the file and offered as extras. messageTurns numbers no turn below 1.
citeLinkOnce settles a throwing snapshot as a failed link and logs it.
* feat(studio): show publisher codes in a card's More Info

Card descriptions can carry the publisher's indicator codes behind
their series (about.source_codes, and source_codes on each drawn
value). More Info shows a one-series card's code under the measure
("World Bank code DT.TDS.DEGG.CD") and each drawn value's code beside
its Shows row. The grouping by publisher and the sentence move into
src/lib/sourceCodes.ts and the SourceCodes atom, which Get Data's
"About this answer" now uses too, so both surfaces word codes the same
way. The description reader drops a malformed code list and keeps the
rest of the description.

* fix(studio): keep card codes no value carries and count hidden chips

More Info dropped the card-level code line whenever any Shows value
carried codes, so a card code that no value carried vanished.
unlistedCodes now keeps the card codes no shown value carries, and the
line shows only when some remain. The per-value chips show three codes
a publisher and now add "+N more" for the rest (sourceCodeChips).
The client at sdk 82701c8 follows spec-kit 4ad7739, whose wire types
carry EnrichmentOptionModel.in_long_layout, so DatasetEnrichment is the
generated type itself. Built from typescript/ and re-locked with
`just relock-client`.
* feat(studio): fit the table card to its rows and columns

A table card was a fixed 22 by 14 cells, so a 40-row table showed five
rows with no sign it scrolled and a 3-row table left half the card
empty. The card now measures its height from its rows, showing ten and
a "Show all N rows" control, and a new card takes the narrowest width
that fits its columns, estimated from Outfit glyph advances in
tableFit.ts. A series table turns on its side when that fits the
default width, and sideways scroll with a sticky first column comes
last. The value column carries the unit, decimals line up per column
and a repeated name shows once per run.

* fix(studio): cap a table's row labels and drop series on redraw

A sticky row label had no width limit, so a long label could cover
every value column even when scrolled fully right. Row labels now stop
at ROW_LABEL_MAX (160px, at most 40% of the table frame) with an
ellipsis and the full text in the cell's title, and tableWidthPx uses
the same cap. A sketch table redrawn with payload cells kept its series
flag and transposed its dimension columns; redraw now deletes it with
namesColumn.
@jm-rivera jm-rivera changed the title release: website b91925c to production release: website 5dee9d0 to production Nov 1, 2026
@jm-rivera
jm-rivera merged commit 720d439 into main Nov 1, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
dev — 5dee9d06 Deployed Nov 1, 2026 by jm-rivera via deploy / deploy #57
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