The agentic console, the v1 JSON API, and the operator manual - #54
Merged
Merged
Conversation
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>
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.
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/platform/web/rtf_platform/api//api/v1— reads, actions, deps, and one error shape.docs/reference/api-v1.mdis the reference.platform/web/rtf_platform/contacts.py,streams.pytemplates/manual.html,docs/OPERATOR-MANUAL.htmltemplates/_design.htmlplatform/schema/029–032platform/infra/build.shThe packaging fix
/consoleanswered 404 on the deployed function since the mount was written, fortwo reasons.
build.shvendorsrtf_platform/and nothing else, so a build inplatform/console/dist— a sibling ofweb/, not a child of the package — wasnever a candidate for the archive. And the path could not have resolved anyway:
DISTwalked two directories up fromconsole_assets.py, which is the rightanswer in a checkout and
/console/distin a Lambda that unpacks to/var/task.Both layouts are now named and searched in order, the build is compiled by
build.shrather than copied from wherever it was left, and when neither layoutholds 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.rtf-platform-prod-consoleand checked against the runningfunction:
/manual200,/console200, deep links 200, a missing asset 404rather than the shell, and the live JS sha256-identical to the bundled file.
Notes
platform/schema/apply.py, not by this merge.one that reads its two live figures from the cluster.