Skip to content

The agentic console, the v1 JSON API, and the operator manual - #54

Merged
mattrickslauer merged 19 commits into
mainfrom
agentic-console
Aug 14, 2026
Merged

mattrickslauer merged 19 commits into
mainfrom
agentic-console

Conversation

@mattrickslauer

Copy link
Copy Markdown
Owner

Nineteen commits: a React console over a versioned JSON API, the counterparty
sources behind it, the operator manual, and the packaging fix that finally puts
the console on the deployed function.

What is in it

platform/console/ The agentic console — three surfaces (Now, Campaigns, New) over the API. Vitest, 30 tests.
platform/web/rtf_platform/api/ /api/v1 — reads, actions, deps, and one error shape. docs/reference/api-v1.md is the reference.
platform/web/rtf_platform/contacts.py, streams.py The counterparty index and stream refresh.
templates/manual.html, docs/OPERATOR-MANUAL.html The operator manual, served and portable.
templates/_design.html One design system. The console generates its tokens from it, so four palettes cannot diverge again.
platform/schema/029032 Contact harvest, role from source evidence, stream refresh, feed contact harvest.
platform/infra/build.sh The console build goes in the zip.

The packaging fix

/console answered 404 on the deployed function since the mount was written, for
two reasons. build.sh vendors rtf_platform/ and nothing else, so a build in
platform/console/dist — a sibling of web/, not a child of the package — was
never a candidate for the archive. And the path could not have resolved anyway:
DIST walked two directories up from console_assets.py, which is the right
answer in a checkout and /console/dist in a Lambda that unpacks to /var/task.

Both layouts are now named and searched in order, the build is compiled by
build.sh rather than copied from wherever it was left, and when neither layout
holds a build the not-built page prints both paths instead of sending a
deployment reader to npm.

Verified

  • platform/web: 611 passed, 251 skipped (the skips need the test cluster).
  • platform/console: 30 passed.
  • Deployed to rtf-platform-prod-console and checked against the running
    function: /manual 200, /console 200, deep links 200, a missing asset 404
    rather than the shell, and the live JS sha256-identical to the bundled file.

Notes

  • The migrations are applied by platform/schema/apply.py, not by this merge.
  • Two copies of the manual now exist. The template is authoritative — it is the
    one that reads its two live figures from the cluster.

mattrickslauer and others added 19 commits August 13, 2026 21:03
Four palettes were in this repository and they were not four designs — they
were one design typed out four times, drifting. The manual's dark accent and
the landing's were #ffb340 arrived at twice; _public.html's accent was the
console's --meas arrived at a third time. `_design.html` is the place that
stops the next one.

It ships as a Jinja include rather than a stylesheet because there is no static
mount in this app and adding one is a change to main.py, a route, a cache-header
decision and a second thing to get right in Lambda. The include costs none of
that for the same single source of truth. Revisit when the React console needs
a .css file it can import.

Two grounds, because the surfaces have different jobs: `transmitter` is pinned
dark for the landing page and the console — a room you monitor at night — and
`paper` follows the reader, for a document read once in daylight.

The manual is now served at /manual, public, the second half of the public
surface rather than a file in docs/. Its counterparty figures are read off the
cluster on page load, and when the cluster does not answer it says so instead
of printing the number that was true when it was written. A manual whose own
section eleven explains that this product refuses stale figures must not serve
one; test_public_surface.py asserts the prose number cannot come back as a
fallback.

The load-bearing test is the other one. The manual teaches ● measured, ○
inferred, ◆ asserted, and the console draws them; they are now asserted equal
per ground, so the manual cannot quietly become a legend for a different
instrument. That drift renders perfectly and is invisible unless you put two
pages side by side, which nobody does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every builder did two jobs: run a statement, and shape the result into columns,
chips and inspector sections. That was correct while Jinja was the only reader.
A JSON API is a second reader, and the shaping half is exactly the half it must
not have — it turns None into "—", 0.42 into "0.42", a bool into "yes" and a
timestamp into "08-13 14:22", so a client receiving it could not sort by score,
format a date, or tell an absent value from a literal em-dash.

So the statement half is now its own function — rows_*, counts_* — sitting
directly above the view that shapes it, and both surfaces call that one. The
alternative was a second copy of every query in the API package, which is how
two front ends drift apart: one gains a tenant_id predicate the other never
gets, one learns that status = 'live' matters and the other quietly quotes
retracted facts back at an operator approving a send.

The SQL is moved, not rewritten. test_research_views.py still proves the same
thing about the same text.

Two things were pulled out as domain logic rather than shaping, because they
are decisions and not renderings:

  * fleet_agents() derives the four-way agent state. `off` and `declared` look
    almost identical on a screen and mean opposite things — a switch somebody
    threw, versus a manifest ahead of its implementation — and two front ends
    deriving that independently is two chances to get it backwards.
  * thread_progress() places a thread on the walk to a close.

rows_counterparties() gained three optional filters, which reach the database
rather than the browser for the reason LIMIT exists at all: a client that
filters what it was sent is filtering a page, not a table. The console passes
none of them and gets the statement it had before. `searchable` is tri-state
because "cannot be seen by R1" is a specific, fixable state worth asking for,
not the absence of a default.

Verified against the cluster: all fourteen builders and all twenty-three
extracted queries execute against the live tenant, and the new filters
discriminate (searchable=False returns 31 of 14,170; a bogus state returns 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A React/TypeScript app at platform/console/, additive rather than a rewrite. The
thirteen Jinja views stay: they are dense reference tables that a server-rendered
page does well, and porting them five days before a deadline would put working
screens at risk to gain nothing. What this adds is the part they cannot be.

  Now        the proposal stream. Opens on what needs a person, each item
             carrying its own reasoning one keystroke away and its own controls
             in reach. An empty list is the correct state and says so.
  Campaigns  a campaign drawn as the signal chain it is — discover, rank, open,
             draft, approve, send — with real counts and a pip per stage. Fine
             control is here: pin, veto, re-run from a stage. There is one open
             button per contact and none for the batch, because opening a thread
             reserves that contact label-wide and a bulk control would hide that
             cost at the moment you incur it.
  New        state a goal, see the stages that would run, what each may spend,
             and where it stops for you — before the campaign exists. Not a
             chatbot and not an invented plan: the stages are the fleet's, the
             caps are spend.py's, and nothing parses the goal text.

The palette is not copied. scripts/sync-tokens.mjs generates design.generated.css
from templates/_design.html before every dev and build, and `--check` fails CI if
it is stale. The generated file is gitignored so it cannot drift across a
checkout. Maintaining a second CSS file with the same hex values in it is the
exact failure _design.html was extracted to stop, and reintroducing it in the app
that motivated the extraction would be a poor joke.

client.ts keeps the Python side's character on the wire. Five failure kinds get
five screens: offline, signedOut, missing, refused, broken. `missing` matters
most — an unbuilt endpoint says so rather than rendering empty and letting an
operator conclude there is no work. Nothing falls back to an empty array.

Acting on a proposal is deliberately not optimistic. Opening a thread can be
refused by a unique index only the database can evaluate, and approving twice is
refused by design; a tick that then reverted would teach an operator to distrust
the one screen that must be trusted.

Not yet wired: the API it talks to is being built in parallel, so api/types.ts is
a stated expectation rather than a transcript and every wire assumption is
confined to that one file. Nothing serves the built assets yet either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urce

The counterparty index holds 14,173 rows and cannot address one of them.
`INSERT INTO contact_route` appears twice in the tree: the send self-test and
a test fixture. Every harvest stage declines to write a route on purpose --
018 accepts a published route as `measured` and calls a pattern guess the
fastest way to earn a spam complaint, so the loaders stopped at `presence`
and left the gap clean rather than filling it with guesses.

So this surveys sources by whether they carry a route the publisher declared,
not by row count, and the ranking that falls out is not the expected one.

Podcast feeds win, and the work is nearly done already. Measured against 45
music-category feeds on 2026-08-13: 40 carry `<itunes:owner><itunes:email>`,
and only 2 of those 40 are hosting-platform boilerplate. Podcast Index does
not publish the address -- `podcastindex.py` says so and is right -- but it
publishes the feed URL, and the feed is one GET away. 023 and 027 already
shipped the source, the manifest and the agent; what remains is one XML field.

Non-US radio is second because it costs nothing: 61,024 stations across 241
countries, 53,297 of them outside the US, at 95-99% homepage coverage --
better than the US on that field. `--index-streams DE,GB,FR` works today.

Curators are a dead end and the reason is structural rather than accidental.
Spotify's terms close it three separate ways, IV.3.1.a on building the
database and V.6 on mailing anyone found in it. Chartmetric indexes twelve
million curators and states it holds none of their addresses. SubmitHub and
Groover hold the routes and rent them per submission, which is precisely why
no free source exists: the route is the product. Sync closes the same way,
with IMDb's bulk credits licensed for non-commercial use only and both guild
directories gated. Press and sync were not researched to the same depth and
the document says so in its own headings rather than padding them out.

The n=54 homepage sample is written up as an argument against the obvious
alternative. ~20% yield sounds workable until you read what came back: a
placeholder, a font vendor's address lifted from a CSS comment, and one
podcast's address on two unrelated iHeart pages. 018 makes routes durable by
design -- DO NOTHING on conflict, `opted_out` terminal -- so a stage that
writes a stranger into that table has not made a mess that a re-run cleans up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim that this console fails honestly is a product claim, and a product
claim is worth what its tests are worth. Twenty of them, covering the parts that
look identical when wrong:

  - a 404 renders "that part of the API is not built yet", and specifically NOT
    "nothing needs you". Those two blank-looking screens mean opposite things,
    and this surface exists so an operator does not have to check the others.
  - an HTML 200 becomes "you are signed out" rather than "Unexpected token '<'".
    That is the expired-cookie path: the Python side 303s to the landing page,
    fetch follows it, and the console gets a page where it expected data.
  - the server's own sentence survives the wire verbatim, because that text is
    what an operator quotes when asking for help.
  - money() rounds down. Rounding up shows a cap as breached when it is not.
  - scaleDistances spreads a tight cluster: 0.0912 and 0.0925 are meaningfully
    different and a 0-1 scale draws both as full bars.
  - the gate says "no sender connected" instead of reading as healthy.

The first run failed, and the failure was in the harness rather than the product:
Testing Library registers its cleanup hook only under `globals: true`, so with
`globals: false` every render leaked into the next test and `screen` searched all
of them. A surface appeared to render its empty state and its error state at
once, which is a convincing-looking bug that does not exist. src/test-setup.ts
registers the hook explicitly and says why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API landed shaped around the existing domain — /summary, /suggestions,
/approvals, /campaigns — rather than the composite endpoints my provisional
types.ts guessed at. Reconciled, and the reconciliation removed work rather than
adding it:

  - /gate deleted before it was ever asked for. /summary already carries
    sender_wired and queued_unsent, so the persistent send-gate indicator is
    built from the endpoint the API explicitly recommends polling. One fewer
    endpoint for the server to own.
  - /intent/plan dropped for the same reason. Every input already had an
    endpoint, and a server-side planner would have been a second place that
    knows what a campaign does.
  - Campaign counts moved to the funnel object the wire actually sends.

The intent surface is now composed from what is true rather than from what would
be convenient. plan.ts derives each stage's feasibility from /summary: send reads
"blocked — no mail provider is wired. Approving still prepares and records the
send", and shortlist reads blocked on any channel but radio because the index
holds radio today. Where the summary has not arrived a stage reads "unknown", not
"ready" — undefined is not false, and an unchecked stage must not look healthy.

Seven tests on that logic, including one asserting that approve is a gate on
every channel. If a channel ever renders without it, the console is
misrepresenting the product's central guarantee, and that is worth a test that
looks paranoid.

Still outstanding: /today and POST /campaigns, both requested from the API
worker. Until /today exists the Now surface renders "that part of the API is not
built yet", which is the correct thing for it to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…at it

Verified both public pages against the live cluster: /manual renders 14,170
counterparties and 10,832 with a genre, /  agrees, and the numbers are three
lower than the 14,173 migration 026 measured this morning. That drift within a
day is the argument for wiring the figure live rather than typing it.

Reading the rendered page turned up a latent bug in the test, not the page. The
dark media query was guarded `:not([data-ground="paper-light"]):not([data-ground=
"transmitter"])`, which put that string in the file twice — once as a guard, once
as the real block. test_manual_legend_matches_what_the_console_draws searched for
the first occurrence and brace-matched the media query instead of the transmitter
rule. It passed, because those two blocks happen to agree on the three provenance
colours, and it would have gone on passing if the transmitter block had drifted,
which is the one thing it exists to catch.

The guard is now `:not([data-ground])` — absence rather than a particular value —
and `paper-light` is gone, having never been set by anything. The test asserts
--ground is the value the block it wanted defines before comparing anything
inside it, so a wrong match now fails loudly rather than passing quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/console` serves the compiled React app. The mount is unconditional and the
handler is what changes, which is the whole design:

`if dist.exists(): app.mount(...)` is the obvious version and it fails in the way
this codebase keeps refusing. On a checkout where nobody has run `npm run build`,
a conditional mount answers 404 — the same 404 as a typo, a deleted route or a
bad deployment — and the most likely reader of it is someone who has just cloned
the repository and has no reason to suspect a missing build step. So the absent
build is served rather than omitted: a 503 page saying the console has not been
built and giving the command, in the product's own palette.

A file request never falls through to the application shell. `StaticFiles(html=
True)` covers `/console` and nothing else, so every client route 404s on reload;
but the naive fix — serve index.html for anything not on disk — means a missing
chunk answers 200 with HTML, the browser parses markup as JavaScript, and the
console dies with a syntax error naming a line in a file that was never sent.
Anything whose last segment has an extension resolves as a file or 404s.

`resolve_asset` resolves first and checks containment second, because that is the
only order that catches `..`. `FileResponse` serves whatever Path it is handed,
so without it a request for /console/../../../.env is a read of any file this
process can open, arriving dressed as a request for a stylesheet.

Verified against a running server: /console and /console/campaigns/abc serve the
shell, /console/assets/nope.js 404s rather than returning HTML, and traversal
attempts at .env, package.json and /etc/passwd all 404 or get the harmless shell.
No leak.

main.py is deliberately not in this commit — it now imports the API package,
which is not committed yet, and committing the two-line mount on its own would
leave the tree broken at this revision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
026 wrote a `role` for every counterparty whose source answered what kind of
thing it was, keyed on `party_fact.source`, and refused the remaining 1,651 on
the grounds that the only other signal was their name. The refusal is right and
this does not reverse it. What it does is apply 026's own test -- does the
source say so -- through the two tables it did not look at.

1,625 of the 1,651 carry `party_identifier(kind='radiobrowser_uuid',
provenance='measured')`. They are not parties whose source was silent; they are
Radio Browser stations whose entry carried no tags, so `index_streams` had no
genre fact to write and wrote no `party_fact` row at all. The entry's own
primary key was on the row the whole time. A UUID is a citation into somebody
else's register; a name is an inference about the world, which is the distance
that matters here.

The 25 Deezer curators get `playlist curator`, `asserted`, on their `party_role`
and `deezer` document. Not `radio station`: writing that would reintroduce by
hand the default 023 removed.

Dry-run against the live cluster on 2026-08-13, as SELECT counts mirroring each
statement: 026 covers 12,519, this adds 1,625 + 25, and one row is left
unclassified -- the delivery self-test fixture, which is not a station and has
no source to ask.

Not applied. Neither is 026.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… once

1,651 counterparties on the cluster hold one identical embedded document --
`Programming not documented. A radio station.` -- so they embed to one vector
and sit at the same cosine distance from every query. Indexed, and not
rankable. 14,170 counterparties, 10,841 distinct profile documents.

`index_streams` stores a station's tags and its homepage and lets the rest of
the payload go. `countrycode`, `state` and `language` reached
`radiobrowser.profile_text` as prose and never became facts, so
`profiles.from_facts` -- the rebuild path -- cannot see them. `refresh_stream`
re-reads those entries by UUID and writes what was dropped, bucketed by the
first hex character of the UUID, sixteen leads, selection by "has no
country_code fact" rather than a cursor so a killed run loses nothing.

Sampled against the live API on 2026-08-13, forty of the untagged parties:
countrycode 40/40, homepage 39/40, state 13/40, language 5/40, **tags 0/40**.
That last line is why the migration does not claim to fix the tie. Radio Browser
does not know what these stations play and re-reading it does not change that;
expect roughly eleven hundred rows to keep the identical document, and there is
no source we can lawfully reach that would say otherwise. The remaining input is
the station's name, which is the inference 026 and profiles.py rule 2 refuse.

What justifies it is the country. 024 will not let a contact route be written
for a party whose country nobody established, and 5,442 counterparties have a
homepage and no FCC licence -- so they are blocked on this and nothing else.

Market is deliberately not written when only a country is known: "Broadcasts to
The United States Of America." in thousands of documents is profiles.py rule 1
being violated by the module meant to repair it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d layer

The console stays Jinja + htmx. This adds /api/v1 as a mounted sub-application so
a React client has something to talk to, and the whole design question was whether
it should call research.py or fork it.

It calls it. research.py's builders were view-shaped — Col, Section, "—" for every
absent value, "yes" for every true one, scores as "0.42", timestamps as "08-13
14:22" — so reuse was not straightforwardly available. The previous commit split
the statements out; this commit consumes them. shapes.py is the API's own shaping
and its rules are the opposite of Jinja's on purpose: null stays null, numbers stay
numbers, money stays in micro-dollars. There is no SQL in the api package at all,
and a test parses the AST to keep it that way.

Mounted rather than included, because the two have genuinely different contracts.
routes.require_operator answers an unauthenticated browser with a 303 to the page
that explains what this is; deps.require_operator answers a client with a 401 and
a code, because a client following that 303 gets a 200 full of markup and reads it
as success. Same gate, different refusal. Sharing an exception handler would mean
discriminating by path prefix inside it.

Refusals carry a machine code AND a human sentence. The sentence is the product —
/approvals does not say "409 UniqueViolation", it says what happened — but a client
that branched on English would break every time the English improved, and it does.
The code set is closed; Refusal rejects one that is not declared. No driver text is
passed through: a constraint name tells an operator nothing.

Guarantees, verified against the live cluster rather than the docs (a scratch
campaign, thread and draft, then deleted; the counterparty's contact_state
restored; residue confirmed zero):

  * double approve -> 409 already_queued, and outbox still holds exactly 1 row for
    that message. UNIQUE (message_id) refused the second insert.
  * second thread on one counterparty -> 409 thread_occupied, across a different
    campaign. one_open_thread_per_counterparty is label-wide.
  * closed_maybe -> 400 naming closed_won, closed_lost, closed_no_reply.
  * sent -> closed_no_reply -> 409 transition_refused naming what is legal instead.
  * tiktok -> 400 naming the five channels in campaign_channel_known.
  * opening a thread computed decided_at_hlc/rank/distance server-side; there is no
    parameter through which a caller could supply them, and a test asserts the
    signature.
  * all 15 read endpoints json.dumps with no default= against 14,170 real
    counterparties, 45,110 facts and 40,866 runs.

Streaming assessed and refused. Mangum buffers the whole ASGI response and does not
implement Function URL streaming, so an SSE endpoint would work under uvicorn and
hang in production — breaking the one invariant main.py exists to protect. On top
of that changefeed.follow needs two dedicated connections per subscriber and its
Wake deliberately carries no payload, so a feed of wakes is a poll with extra
infrastructure. /api/v1/summary is one round trip for eleven numbers instead.

Two small extractions elsewhere, both to stop the surfaces drifting rather than for
their own sake: routes._thread_of and the campaign party_id lookup moved into
research.py so both front ends resolve a draft the same way, and repo.why_unacceptable
is now one predicate that accept_suggestion branches on and /today reads to say in
advance that Accept would be refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ritten

`contact_route` held one row against 14,170 counterparties, and that row is the
delivery self-test address a human typed. Every loader declined to write one on
purpose -- 018 accepts a published route as `measured` and calls a pattern guess
the fastest way to earn a spam complaint -- so the gap was clean rather than
poisoned. This fills it, twice, and refuses everything in between.

`harvest_feed_contacts` reads `<itunes:owner><itunes:email>` out of a show's own
RSS. `14-counterparty-sources.md` ranks it first among every source surveyed and
the ranking is not close: 40 of 45 music feeds declare one, only 2 of those 40
being hosting-platform boilerplate, and the element exists because Apple
requires it so the directory can reach the owner. Its only purpose is to say
where to write. podcastindex.py's header is right that the API omits it and the
API returns the feed URL. The owner is read with a regex, not an XML parser: a
feed is a document a stranger controls and `xml.etree` is documented as
vulnerable to entity expansion, and two elements do not need a parse tree.

`harvest_contacts` reads a counterparty's own website, and needs the argument
its ranking implies. The first version scanned page bodies for anything shaped
like an address; §11 of the same research measured what that returns over 54
station homepages -- of eleven hits, one placeholder, one font vendor's address
lifted out of a CSS attribution comment, and one podcast advertisement that
appeared on two unrelated broadcasters. Since 018 makes routes durable by
design, that is a permanent wrong claim rather than a mess a re-run cleans up.
The body scan is gone. What is left is §11's own prescription: structured
declarations only -- a `mailto:`/`tel:` anchor or a contact page's own form --
and an address only counts as declared when it is on the counterparty's domain
or on a page the site itself routes as contact/submissions/staff.

Proved against six real station sites on 2026-08-13 through the production read
path: `submissions@wnxp.org`, `music@wprb.com` off WPRB's own submissions page,
four named staff addresses at KTXK, a form and three numbers at WZEE -- and
nothing at all for KFCF, whose addresses sit on pages its navigation does not
label. Nothing junk in that run. Abstaining on KFCF is the stage working.

No route is written without a country. 024 argues that a row nobody placed must
not be silently domiciled in Virginia; `country_for` reads US off an FCC
facility ID or an alpha-2 off a `country_code` fact and raises otherwise, naming
what would fix it. There is no TLD branch and no state-code branch -- ISO `AL`
is Albania. 029 lifts 024's nullable column and shape CHECK forward, because
they need no regions, and deliberately leaves the `SET NOT NULL` gate in 024
where it belongs. A feed-harvested route is written with a NULL country and will
therefore stop that gate until a human answers §10's question about individual
subscribers, which is a gate behaving correctly.

`plan()` re-checks every value against the bytes it was read from and raises
`NotPublished` otherwise, so a synthesised address is unrepresentable rather
than discouraged. Evidence documents carry `party_id = NULL`: `_fetch_embed_party`
embeds a party's most recent document, and a page of email addresses must not
become the text R1 ranks a station on, nor enter the retrieval corpus.

Neither stage has run. 029 and 032 are unapplied, and the cluster holds zero
podcast counterparties -- 023 and 027 are unapplied too and no key is set -- so
`harvest_feed_contacts` is tested offline and unexercised against a real feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every statement in 029/030/031/032 was executed against the live cluster inside
an explicit transaction and rolled back, to prove the SQL parses and binds
before anyone applies it. The INSERTs rolled back cleanly. The three ALTER
TABLEs did not: CockroachDB runs a schema change as a job outside the
transaction's control, so `contact_country` and `route_country_shape` were still
on `contact_route` after the ROLLBACK.

Both were removed again immediately -- one row in the table, no data in the
column -- and `contact_route` is back to its 018 shape, verified. Noted in the
file rather than quietly fixed: a rolled-back transaction is not a dry run for
DDL on this engine, and a column that briefly existed leaves a row in SHOW JOBS
that would otherwise have no explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps the client author found by building against it.

FastAPI's own request-validation failures bypassed the envelope: a handler's
refusal arrived as {"error": {...}} and a missing body field arrived as
{"detail": [...]} at 422. Two shapes on one API, and a client would have found
out about the second in production. Translated now, keeping the framework's field
list under error.fields — the only part of any refusal that names which field was
wrong, and the one place the no-driver-text rule does not apply, because it
describes the caller's own request rather than the schema. jsonable_encoder
because a pydantic error can carry a ValueError in ctx, which json.dumps cannot
serialise; without it the 422 would have become a 500.

The sentence is also mirrored at the top level as `message`. Their error reader
looks for detail, then message, then error — and with the old shape it reached
`error`, an object, which renders as [object Object]. Derived from the same field
in Refusal.body, so the two cannot drift.

docs/reference/api-v1.md is written: every endpoint, every field, every refusal
code, and the limits stated as decisions rather than left as surprises.

Two things in the reference are pushback rather than description, and both are
flagged there:

  * POST /campaigns takes no cap. Caps are per-artist in party_budget and are read
    through /budgets; campaign has no cap column. Accepting the field and ignoring
    it would be the silent default this codebase forbids. It does require `name`,
    which the requested body omitted — campaign.name is NOT NULL and a name derived
    from the artist and the date is a label nobody chose appearing in every list.
  * refused_because can never carry the duplicate-approval case, structurally.
    /today holds no drafts, and a draft only sits at the gate while no outbox row
    exists for it — the first approve writes one and moves the thread to queued in
    the same transaction, at which point it is off /approvals. A control
    pre-disabled on that would be a control that is never disabled. Retry and
    handle already_queued; that is what the constraint is for.

The 26 cluster tests were run attended against the live cluster in scratch tenants
created and dropped per test (unittest directly, bypassing the conftest fence on
purpose, with a human watching). All 26 pass. Residue afterwards: one tenant, zero
orphaned rows in thread, campaign, outbox, message, party or lead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… possible

Ran it against the live cluster and it rendered a black rectangle. The API answers
/today with its listing envelope; App.tsx read `today.data?.proposals.filter(...)`;
the optional chain guarded `data` and stopped there. One undefined property
unmounted the entire React tree — 200 response, healthy server, nothing on screen.
Then the same bug again on /new, via `budgets.data?.budgets.find(...)`.

Three fixes, in increasing order of how much they matter:

  1. The shapes are now the ones the API actually sends. /today returns
     {rows, returned, quiet}; every collection returns {rows, limit, returned,
     total, truncated, ...}. Actions carry their own endpoint and a `per` scope,
     so the server owns the address and a route can move without a client
     release. `per: "candidate"` fans one press across the group, because the
     manual is explicit that a suggestion group is one decision per artist —
     "accept or reject the lot, in one go" — and the button now says "Accept
     all 5" so a single press cannot be misread as accepting one of five.

  2. No call site names the array any more. client.getListing unwraps and
     validates `rows` in one place, so a body that does not carry one becomes an
     ApiError the surfaces already render as a sentence. Correcting three
     property names would have fixed today's bug and left the next one.

  3. An ErrorBoundary, because a render fault must never again be a blank page.
     It caught a live crash on /new the first time it ran. A blank page is the
     worst failure this console has — worse than any of the five the client
     already handles — because those at least render a sentence, and a black
     rectangle is indistinguishable from a broken deploy, a CSS mistake or a
     hung request.

From the API author's pushback, both accepted: POST /campaigns takes no cap,
because caps live per-artist in party_budget and accepting a field it would
ignore is a silent default — the surface now shows the artist's real budget
instead. And it requires `name`, which the client had omitted.

Two things seen only by looking at it. The top row was `auto` and collapsed to a
20px sliver, which reads as a broken page; it is 44px now. And `act` and `warn`
were #ffb340 against #d2a24c — two ambers nobody tells apart down a list. The
Jinja console had already settled this: act is the red one. A second console
inventing its own severity scale is how an operator learns to distrust both.

main.py joins this commit now that the api package it imports is committed.

605 python tests, 30 console tests, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified the Campaigns surface against the live cluster and the state chips were
stretching to fill their 128px grid track. A full-width bordered box with a word
in it is not a chip — it reads as an empty text input, and invites a click that
does nothing.

Third of the three surfaces checked against production this way. The other two
turned up the collapsed top row and the two indistinguishable ambers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/console` has answered 404 on the deployed function since the mount was written,
and the module docstring said why in the present tense: the build was never in the
deployment package. Two things kept it out.

`build.sh` vendors `rtf_platform/` and nothing else, so a build sitting in
`platform/console/dist` — a sibling of `web/`, not a child of the package — was
never a candidate for the archive. It is now copied to `rtf_platform/console_dist`,
which is inside the only directory that ships.

And the path could not have resolved even if it had been copied alongside. `DIST`
walked two directories up from `console_assets.py`, which is `platform/console/dist`
in a checkout and `/console/dist` in a Lambda that unpacks to `/var/task` — an
absolute path outside the function, on a filesystem that has no `platform/`. Both
layouts are now named and searched in order, and when neither holds a build the
not-built page prints both rather than sending a deployment reader to `npm`.

The console is compiled by `build.sh` rather than copied from wherever it was left:
a stale `dist/` would deploy as an older bundle with nothing in the plan, the apply
or the running function to say so. Sourcemaps stay out — 85% of the output, read by
nobody in Lambda — and the pointer to them goes too, so opening devtools does not
fetch a 404 that looks exactly like the missing chunk this module works to keep
legible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourteen sections, self-contained, no build step and no server — the same shape as
`docs/deck/remixkit-deck.html`, and readable from a file:// URL by someone who has
neither a checkout nor a session.

It is a second copy of what `/manual` serves from
`platform/web/rtf_platform/templates/manual.html`, and copies drift. This one is
the portable rendering and the template is the served one; the template is
authoritative, because it is the only one of the two that reads its two live
figures from the cluster rather than printing what was true when it was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mattrickslauer
mattrickslauer merged commit 074ba8f into main Aug 14, 2026
2 checks passed
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