Skip to content

Console template editor - #289

Open
zer0stars wants to merge 18 commits into
masterfrom
template-editor
Open

zer0stars wants to merge 18 commits into
masterfrom
template-editor

Conversation

@zer0stars

Copy link
Copy Markdown
Member

The editor for the trim templates the extraction pipeline emits.

Trims are columns, attributes are rows. Values shared across trims render
muted; values that differ render at full contrast, so on the real Camry the eye
lands on powertrain_type and fuel_tank_capacity_gal — the pair the
production record blended into one row describing two different cars.

  • /templates browses device definitions and says which have a template, which
    do not yet, and which carry an id the schema cannot accept
    (subaru_tribeca-(ny/nj)_2008 and 136 others are live today).
  • /templates/<id> edits one. Publish is disabled while the draft would fail
    the worker, and the fault is named in the worker's own words.
  • /templates/new creates one.

The write path

DEFINITIONS_WRITE_TOKEN lives only in src/services/definitions.ts, which
throws if it is ever evaluated in a browser — a guarantee rather than a
convention, and tested both ways. /api/templates/[id] identifies the caller
from the session Console already has, stamps author from it, and refuses a
body that names its own author or version rather than stripping it quietly.

Entitlement follows risk: creating a template harms nobody; editing one that
4,212 vehicles resolve to re-describes all of them. hardwareTemplateId is
curator-only at every tier and has no UI at all.

The If-Match forwarded to the worker is the client's, never the version
the route just read — the latter would rebase a stale editor onto whatever
landed while it was open and return 200.

Needs review

  • An assumption to confirm. The spec says only "its author" may edit an
    unreferenced template, but backfill templates carry no author, which under a
    literal reading makes the whole catalog curator-only. This treats an absent
    author as unowned. One function changes if that is wrong
    (resolveEntitlement).
  • The proposal tier is not built — editing a referenced template you do not
    own needs a proposal store the worker does not have. Those callers get a
    read-only editor that says exactly why.

Prerequisites

Requires definitions-worker
#4 (schema route,
compare-and-swap, generated types) deployed first. Deployment is still gated on
dd-api's Create()/manifest() migration and a production re-run of the
extraction.

Verification

203 tests passing, up from 100. This repo's master is red before this branch:
19 suites / 20 tests fail on stale expectations unrelated to templates (a
toEqual on the whole config object, components that dropped a role). The
failing set is byte-identical before and after this branch. npx tsc --noEmit
reports zero errors under src/. npm run build succeeds.

Note: CLAUDE.md says npm run compile is a typecheck; it is graphql-codegen.

Types are generated from the worker's schema rather than hand-written a third
time, and the vocabulary is fetched at runtime so the editor form cannot offer
an option the validator rejects.

A runtime guard makes the server-only rule a guarantee instead of a
convention: DEFINITIONS_WRITE_TOKEN cannot reach a browser bundle without
throwing. The guard is testable both ways -- definitions.test.ts runs under
@jest-environment node, and definitionsServerOnly.test.ts asserts the module
refuses to load under jsdom.
Trims as columns, attributes as rows, and the two contract invariants enforced
in the model rather than left to the UI: an attribute lives on the template or
on the trims and never both, and clearing removes the key rather than storing
an empty value. Editing one trim's value for a shared attribute pushes it down
first, which is what makes divergence expressible at all.

Tested against the real ten-trim Camry, including the regression that
powertrain_type and fuel_tank_capacity_gal are the divergent rows -- the pair
the production record blended.
Every difference between what someone types and what gets stored produces a
Normalisation the editor shows before save -- a reparsed number, trimmed
whitespace, a cleared attribute that removes the key. The validation mirror
uses the worker's own error strings so one fault is never described two ways;
it is explicitly not authoritative, and the save path still surfaces the
worker's 422 verbatim.

Fixtures move to src/utils/__fixtures__/. Jest's default testMatch treats
everything under a __tests__ directory as a suite, so a fixture module there
fails as a suite with no tests.
Trims as columns, attributes as rows, agreement muted and difference at full
contrast -- the inverse of normal form emphasis, so the pair of attributes the
production Camry record blended is the pair the eye lands on. The divergence
rail offers Move to shared at one distinct value because that is the correct
action under the template-or-trim rule, and a trim with no effective selector
is marked in the header rather than discovered at save.
Three states, all real. A definition with no template says so rather than
erroring -- the import has not run in production and 5,152 of the emitted set
carry no attributes. An id the schema cannot accept says that instead, because
137 of them are live today and subaru_tribeca-(ny/nj)_2008 is one; those are
never asked about, since the answer would be a misleading 404.
The worker has one shared WRITE_TOKEN and takes author on trust. This route
holds the token server-side, identifies the caller from the session Console
already has, stamps author from it, and refuses a body that tries to name its
own author or version rather than stripping it quietly.

Entitlement follows risk: creating a template harms nobody, editing one that
4,212 vehicles resolve to re-describes all of them. hardwareTemplateId is
curator-only at every tier. The If-Match forwarded is the client's, never the
version this route just read -- the latter would rebase a stale editor onto
whatever landed while it was open and return 200.
…anner

Three places the editor leaves a trace instead of acting quietly: what it
stored that differs from what was typed, why a selector-less trim will be
refused before save rather than at it, and what the caller is not allowed to
do and why.
Publish is disabled while the draft would fail the worker, and the fault is
named in the worker's own words. The precondition is the version that was
loaded, held apart from the draft so it cannot move underneath it. A conflict
explains what happened and offers a reload -- it never retries over the top of
somebody else's version.
Publishes immediately and attributed, because nothing points at a template
nobody has created. If-None-Match makes an id that already exists a conflict
with a link to it rather than a silent overwrite.
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
dimo-developer-console Ready Ready Preview Sep 15, 2026 4:25pm UTC

Request Review

The contract has three generated mirrors (Console TS, the worker's TS,
dd-api's Go). Generating them without a guard is hand-writing them one step
removed: the copies still drift and nothing says so. The worker already fails
`npm test` on drift; Console had the generator and no check.

Two changes make that possible. The generator now reads a vendored copy of
template.schema.json rather than fetching the deployed one: a generator whose
input can change underneath it produces output nobody can reproduce, and drift
is only reviewable when it lands in a diff. `--from <url|path>` rewrites the
vendored schema alongside the types, so a contract change shows both files
moving in one commit.

`--check` then fails when the committed output is stale, wired into `prebuild`
so a Vercel build refuses to ship stale types, and covered by a test that also
asserts the guard fails on a perturbed file -- a check that cannot fail is the
defect it was written to prevent.
…emplate

The worker takes hardwareTemplateId on each trim as well as at the top level
(TRIM_KEYS carries it), and templateGrid.toPayload forwards trim overrides in
the PUT body. The curator-only gate compared only the top-level value, so a
non-curator whose body left that untouched and set
trims[i].hardwareTemplateId was let through with a 200 and the worker stored
it. The route test covered only the top-level case.

hardwareTemplateIdChanged in templateEntitlement now compares the top-level
value and the multiset of (trim name, hardwareTemplateId) pairs between the
submitted body and the stored template. For a non-curator any difference is
refused with the existing 403: a changed trim value, a value on a trim the
stored template does not carry one for, a stored value that goes away with
its field or with its trim, and any value at all on a create, where there is
no stored template to compare against. Trims that come, go or are renamed
without a hardwareTemplateId are still vehicle description and pass. Pairs
are compared per occurrence so a duplicated trim name cannot hide a value.

The PUT route is the only write route; a create is a PUT against an id with
no stored template, which the same comparison covers.
…t question

identity-api reports a GraphQL failure as HTTP 200 with `errors` set and
`data` null. identity() checked only resp.ok and returned `json.data ?? null`,
so countMintedVehicles read the failure as zero vehicles. Zero vehicles on a
template with no author is the open tier: during an identity blip any
signed-in account could publish over toyota_camry_2020 and its 4,212 minted
vehicles. `errors` was never inspected.

identity() now throws an IdentityError when the response is not ok, when
`errors` is non-empty, or when `data` is missing, and countMintedVehicles
throws when the count is not a number. resolveEntitlement catches a failed
count, and a failed Manufacturer NFT holder lookup, and returns a new
'unavailable' kind: canPublish false, canSetHardwareTemplateId false, and a
reason that says to try again. A curator gets the same answer. A create is
unaffected, since nothing can point at a template that does not exist yet.

The PUT route answers 'unavailable' with 503, not 403, because nothing was
refused and the client should retry. entitlementFor reports it to Sentry,
since a returned state never reaches captureException. The GET route now
serves the editor read only during an outage instead of failing with a 502.
The banner titles the state "Access could not be verified".
Per-trim cells rendered only for a row already scoped to a trim, and
nothing in the UI moved an attribute into that scope. The divergence
rail offered "Move to shared" and nothing the other way, so scope could
only ever travel trim -> shared: pushDownToTrims was correct and
reachable only from inside setTrimValue, which itself had no call site
outside the per-trim branch, so it could never fire. A curator could
preserve a divergence the extraction found but never author or correct
one -- the capability this migration exists to add.

The rail gains the inverse offer, "Set per trim", in the same place and
the same pill as its opposite, gated by canSplit on the same rule
canLift uses: a single-trim template says the same thing either way, so
the offer would be noise there. On a shared row it brings the value down
onto every trim, which is what the worker's "on both the template and
trim" rule requires before one trim can disagree. On a row nothing sets,
scope has no data to live in yet, so the grid holds the opened rows in
view state until the first value is typed.
…tract

Every create was a guaranteed 422. The create form builds
`manufacturer: { slug, name }`, and worker 7bbc0db made tokenId a
required positive integer -- a field the form has no input for and must
not have, since it is the Manufacturer NFT id and identity owns it. The
drift guard could not see it either: gen-template-types.mjs --check
compares the generated types against the VENDORED schema, never against
the worker, and the vendored copy was two worker commits stale
(733272b, 2026-08-30), so CI stayed green on a body the worker refuses.

The PUT route now resolves the slug through manufacturerOwner() -- which
it already imported -- and stamps the token id where `author` is
stamped, overruling any value the body carries rather than refusing it,
because the editor round-trips the manufacturer object it loaded. A slug
identity does not know is answered here, naming the slug, instead of as
the worker's message about a field the form does not have; an identity
outage is a 503, the same "nothing was decided" the entitlement tiers
use, not a stamped guess.

Re-vendored src/types/generated/template.schema.json from
definitions-worker fb81596 (schema last changed at 97d364d) and
regenerated the types: manufacturer.tokenId becomes required, and
selectors.vinPattern picks up minLength 1 and the RE2 rules. A new test
reads the vendored schema directly and fails if the token id stops being
required, since the types check alone cannot see a stale vendor.
When the stored template read as null the route discarded the client's
If-Match and sent a create-only precondition instead. A save against a
template someone had deleted -- through the worker's DELETE, dd-api or
dimo-admin -- therefore committed as a create: checkPrecondition(null,
null) passes, the stale draft resurrected the deleted template, and the
editor was told 200. That is the lost update the worker's CAS exists to
make impossible, and the comment above the branch already promised the
precondition always comes from the client.

The header is now read once, before the stored template is consulted.
An If-Match stays an If-Match whatever the read returned, so the worker
answers 412 and the editor learns the template is gone; only a client
that sent no If-Match at all gets the create-only precondition. An
If-Match that is not a version is refused on both branches rather than
silently becoming a create.

The test that pinned the forwarding promise exercised only the non-null
branch; it now covers the null one, and the create case says out loud
that it is about a client that sent no header.
…nt template

The `template === null` branch failed open. It returned canPublish for
any signed-in caller and reported mintedVehicles: 0 as fact without ever
calling countMintedVehicles -- while the field's own doc says an unknown
count is not zero. Template absence is not definition absence: the
production extraction has never been run, Console's own search renders
those rows as 'missing', and identity serves toyota_camry_2020 to around
300k vehicles with no t/toyota_camry_2020.json behind it. Any signed-in
developer could write what all of them resolve to, and the same hole
reopened for every template deleted through the worker.

The count now runs first, for every caller and both branches, and an
absent template goes through the same tiers as a stored one: unavailable
when identity cannot answer, curator, create only when the count is
genuinely zero, manufacturer for the NFT holder, proposal-required for
everyone else. With no stored document to read the manufacturer slug
from, it comes off the make segment of the id -- which the worker
already requires to equal manufacturer.slug, so it is the same value the
document would have carried.

resolveEntitlement takes the definition id as an argument now rather than
reading template.id, since the question is about the definition and the
document may not exist.
The manufacturer code and style name inputs were controlled by the
joined list while the change handler re-parsed with toList on every
keystroke. A typed comma was therefore deleted the instant it appeared:
'2532' + ',' parsed back to ['2532'], which re-rendered as '2532'. A
second selector could not be entered at all, and the placeholder the
component itself shows -- '2532, 2546' -- named a value the control
could not produce. The only workaround, clearing the field first, stored
manufacturerCode: [] on the way and tripped the multi-trim selector-less
trim error on a template that was fine. The same round trip split an
existing styleName containing a comma into two selectors the moment the
field was touched, silently changing which VINs that trim claims.

The field now holds the raw text while it is being typed and parses on
blur, which is what TemplateCell already does for the same reason. An
untouched field commits nothing, so a stored value containing a comma
survives a focus and a blur unsplit.
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