Skip to content

feat: Frame templates - #84

Merged
dcmcand merged 43 commits into
mainfrom
frame-templates
Sep 10, 2026
Merged

dcmcand merged 43 commits into
mainfrom
frame-templates

Conversation

@dcmcand

@dcmcand dcmcand commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #60.

Frame templates give an author a running start: a named starter that prefills content and marks each slot required, recommended, or optional.

What a template is, and is not

A template is an authoring affordance local to this implementation. It is not a Frame and not a Frame Spec concept: it is copied once at creation and never referenced again. docs/adr/0001-frame-templates-are-not-frames.md records why, including why modelling templates as Frames cannot work here (rbac.Can opens with a cross-org deny, so built-ins in a shared org would be invisible to everyone).

A template carries two things: prefill content, and a per-slot rule with a level and a note. The note is what the seeded form shows in place of the generic hint, and what an AI client would ask as a question.

Surfaces

  • Six built-in templates, compiled in and validated at package init, so a malformed starter fails at startup rather than at a user's first click. Built-ins carry rules and notes but no prefill: shipping example prose every author must delete makes the blank page worse.
  • Org templates as rows in frame_templates. Both kinds resolve into one list behind the same read RPCs, so no client can tell them apart.
  • Five RPCs: two reads any member may call, three admin-only writes.
  • Web: a picker at the start of the create flow, a seeded authoring form, and an admin page for managing the org's own templates.
  • CLI: frames template list, frames template init to write a fill-in scaffold, and frames publish --template to check it.
  • The repository's first authenticated blackbox e2e suite, build-tagged so make test is unaffected, wired into the e2e-sandbox job.

The enforcement decision, stated plainly

Requirements are checked only on the publish that names a template id. Nothing is recorded on the Frame. So required is an authoring aid, not a control: an author can empty a required section on their next publish and nothing objects, and any caller that omits the id skips the checks entirely. Real governance would need a persistent model, which is a different design. The ADR records the alternatives considered and what each would have cost.

Unblocks

This settles the modelling question #61 was waiting on. Templates are exposed to AI clients as tools only, never as MCP resources: nebari-frame:// addresses Frame content, and an authoring affordance in that namespace would look like something a client can inherit from or resolve.

#82 tracks browser-level coverage of the authoring and template flows, which is the one gap this branch does not close.

Verification

All 22 Go packages and 189 web tests pass with -race. The template lifecycle, required-slot refusal, built-in immutability, and the copy-once guarantee each have tests; the e2e suite covers the same properties against a real deployment.

Two notes for reviewers:

  • The e2e suite has never run against the sandbox. This PR is the first time that job executes, and its Keycloak client provisioning and audience mapper are the parts most likely to need adjustment.
  • frames publish stores the author's exact bytes, so the CLI scaffold's guidance comments persist into stored content. That is deliberate and documented; the alternative, printing guidance to a terminal that then scrolls away, loses it.

… from it

Publishing with template_id now asserts create intent and refuses the
publish when a required slot is empty, merging those violations with any
schema errors into one response. An unusable template_id (unknown or another
org's) is reported as an invalid publish request, not a missing template, so
existence does not leak. PublishDoc and PublishDocFrom keep their existing
signatures as thin wrappers over the new PublishDocRequest/PublishRequest
entry point, so no existing caller changes.
…not InvalidArgument

Marshal here serializes a document the server already built and validated,
so a failure is a server-side invariant break, not bad caller input. Matches
ConvertFrame's handling of the same call elsewhere in this file.
Once a template is picked, fetch it (getFrameTemplate), parse its prefill
through the same parseFrameContent/docToForm steps the edit path already
uses, and reset the form with the result. Sections the template marks
required or recommended are pre-added rather than hidden behind "+ Add
section", unioned with whatever the prefill itself populated so prefilled
content is never collapsed. A required section's "Remove section" control
is withheld, and a rule's note replaces the generic per-slot hint.

Also widens frameDocSchema.name to default to "" like its sibling metadata
fields: a template's prefill is a strict subset of the Frame schema that
never sets identity fields, so the shared parser must decode a prefill
missing name the same way it already tolerates a missing description or
version.
…n the section

The submit handler now sends templateId via publishTemplateID(), so the
backend can check the chosen template's required slots on create. Doing
so required switching the publish mutation from the callback-style
mutate() to mutateAsync() so the request is a single object rather than
a request plus an onSuccess/onError pair; publishFromMarkdown and
convertFrame are untouched.

No change was needed to render a required-slot violation inside its
section: TerminologyEditor, ListEditor, and MarkdownField already each
render a FieldError for their own slot path, and mapPublishError already
keys fieldErrors by that same path, so the message already lands where
the section is.

Updated FrameAuthoringPage.create.test.tsx's mutation mock to match
(mutateAsync instead of mutate, and a rejected promise instead of an
onError callback) since the assertions it makes did not need to change.
publishFromMarkdown published without templateId, so an author who chose a
template and then switched to the Markdown editor before publishing had the
template's required-slot check silently skipped, unlike publishing from the
document editor. Both paths now compute the id the same way, so the check
an author opted into no longer depends on which editor tab they used.
Adds an admin page for creating, editing, and deleting an org's own Frame
templates, plus the per-slot FieldRuleEditor it uses to set each content
slot's requirement level and author note. Built-in templates get no edit or
delete controls since the backend refuses both for them. Editing replaces
the stored template wholesale, so the form always sends every field, and the
prefill blob is restricted to slots and extends so no identity field can
leak into it.
Adds a test that exercises the actual edit flow: fetch a template row with an
existing extends suggestion, change only the title, and assert the update
request both carries the new title and still carries the original extends
after the round trip through serializeFramePrefill.

Writing that test surfaced a real bug: the edit-seeding effect had no guard
against re-running once the form was populated, so a query result with a
new-but-equivalent object identity (which a background refetch can produce)
would silently re-reset the form and drop whatever the admin had typed. Now
guarded on a ready flag so the seed runs at most once per dialog open.

Also passes the real canManage flag to the built-in template group instead of
a hardcoded false; the per-row gate already excludes built-ins on its own, so
this only removes a confusing, redundant flag.
…ions, lint e2e, gate authoring template query, clarify docs

- Export the slot schema from frame-yaml.ts instead of keeping a byte-identical
  copy in AdminTemplatesPage.tsx, so the ten slot keys have one source of truth.
- Assert the exact slot and level a field rule maps to in the admin template
  and FieldRuleEditor tests, instead of only checking that some rule exists.
- Have `make lint` also run golangci-lint against the build-tagged e2e package,
  which the default invocation can't see.
- Gate the authoring page's template-list query on the picker actually being
  shown, matching how its sibling query is already gated.
- Note in code that the built-ins-first template order is the guarantee the
  CLI relies on, not something the web picker (which groups by origin) follows.
- Correct the ADR's RBAC claim: templates have no per-item grants, but manage
  operations require an admin caller, not just org membership.
- Reword PublishDocRequest's doc comment so it's unambiguous that it loses the
  author's original bytes, and name the path that preserves them instead.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

📄 Docs preview for frame-templates:
https://frame-templates.nebari-frames.pages.dev

…nt works

Keycloak's declarative user profile is enabled by default and marks firstName
and lastName required. A user created without them is left incomplete, and the
direct grant refuses with "Account is not fully set up" even though the account
exists, is enabled, and the password was accepted with temporary=false.

requiredActions is cleared explicitly for the same reason: a realm default such
as UPDATE_PASSWORD blocks the grant identically, and the realm's defaults are
not ours to assume.

The failure path now dumps what Keycloak thinks is outstanding. The error text
alone names no unmet condition, and the account disappears with the cluster, so
without this a repeat costs another full sandbox run to diagnose.
Every RPC in the first real sandbox run failed with "certificate signed by
unknown authority", while the curl-based provisioning steps in the same job
authenticated against the same gateway using the same file. curl and Go do not
agree on what may serve as a trust anchor: Go requires the CA basic constraint
that curl will do without.

The harness now loads FRAMES_E2E_CA_CERT into the root pool itself, alongside
the system roots. Verification stays on; a suite that skips certificate checks
is not exercising the path it claims to. A file that holds no certificate now
fails by name against its path rather than surfacing later as an unexplained
verification error.

The step also prints the anchor's subject, issuer and basic constraints, and the
chain the gateway presents, before running the suite. The error text names no
cause and the cluster is gone by the time anyone reads the log, so without this
a repeat costs another full sandbox run to explain.
The sandbox's advertised CA file is a self-signed leaf: subject equals issuer,
CA:FALSE marked critical, and the gateway presents a chain of one. It cannot be
a trust anchor. Go enforces basic constraints and refuses it, which is why the
same file verifies under curl, where OpenSSL will anchor on a self-signed
certificate by exact match, and fails under Go with "certificate signed by
unknown authority". Adding it to a root pool does not help, because the pool is
still asked to build a chain.

The harness now branches on what the file actually holds. A real CA is added to
the root pool and verified normally. A self-signed leaf is pinned instead:
chain building is turned off and replaced with a byte-identical comparison
against the certificate on disk, which is a stricter test than chain
verification rather than a weaker one. Any other certificate fails, whoever
signed it.

The alternative was to skip verification, which would have made the suite prove
nothing about the transport it claims to exercise.
The probe after the CoreDNS patch fired a single nslookup and failed the job on
one negative result. Across four sandbox runs it failed, passed, passed, failed,
so roughly half of them died before reaching anything under test.

It is a race, not breakage. `rollout status` reports the new CoreDNS pods Ready
before the new Corefile is necessarily live in them: a ConfigMap mounted as a
volume propagates on kubelet's sync interval, and CoreDNS reloads on a poll of
its own. Sampling at the earliest possible instant loses that race often, and a
lost race looks exactly like the misconfiguration the probe exists to catch.

Now retries to a two-minute deadline, so slow propagation reads as slow and only
genuine breakage reads as broken, and dumps the Corefile the ConfigMap actually
holds when it does give up.

Unrelated to the templates work in this branch; it is fixed here because the
flake blocked this branch from demonstrating its own e2e suite. Stands alone and
can be cherry-picked.
…he action reports

Pinning against the sandbox action's ca-cert-path failed with a
presented-versus-pinned mismatch. That file is a self-signed leaf, not an
authority: CA:FALSE, key usage Digital Signature and Key Encipherment, no
Certificate Sign. It is also not the certificate this gateway presents. curl
never noticed, because OpenSSL will anchor on a self-signed certificate by exact
match and the provisioning calls talk to a different host.

The serving certificate now comes from the nebari-gateway-tls secret over the
authenticated kubeconfig, so the expected identity is learned from a trusted
channel rather than from the connection under test, which is what pinning
whatever the server happens to offer would amount to.

Also prints the fingerprints of the reported path, the secret, and what the
gateway actually serves. Three runs were spent characterising a mismatch that
one line of output would have explained.
The e2e-sandbox job pinned envoy-gateway-system/nebari-gateway-tls, which is
the catch-all HTTPS listener's certificate. The nebari-operator gives each
NebariApp its own Gateway listener with its own certificate whose only SAN is
that app's hostname, and Envoy serves that one for the app's SNI - so the
suite pinned a certificate the server never presents and failed with a
presented-versus-pinned mismatch.

Verified against a live cluster: nebari-gateway-tls fingerprints 28:24:56:...
(SANs apex, keycloak, argocd) while frames.<domain> presents E0:41:9B:...,
matching the per-app secret the operator created. The job now resolves the
certificate from the listener whose hostname is under test, which also
survives a rename of the operator's "<app>-<namespace>-tls" convention.

With that fixed the whole suite passes against a real deployment with real
auth: 6/6, through Envoy, on an operator-provisioned OIDC client.

Also corrects a wrong explanation that was in both harness.go and the job.
Go does not refuse a self-signed CA:FALSE leaf as a trust anchor: x509
short-circuits on opts.Roots.contains(c) and returns a one-element chain
without consulting basic constraints. Pinning is still the right choice, but
because it states the exact-match requirement in our code rather than
inheriting it from an x509 internal - not because chaining would fail. That
mattered practically, since it made "unknown authority" look like a
constraints problem when it was really the wrong certificate.

docs/qa/e2e-token-acquisition.md gains the cluster-specific half of this: the
audience is the operator's SPA client read from its secret, Keycloak's
declarative user profile requires firstName/lastName, and the trust bundle
mounted into the pod is a separate concern that must carry public roots too.
@dcmcand
dcmcand requested a review from jbouder September 8, 2026 16:23

@jbouder jbouder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocker inline: saving a template then reopening Edit seeds the dialog from the stale cached row, so a second Save reverts the first edit. Four should-fix items follow, all on the same theme of --template forcing create intent and the web surfacing (or not surfacing) the resulting errors. The rest of what I found is minor and can follow up.

// form - discarding whatever the admin had already typed - every time
// that reference changes. Once ready, this effect is a no-op for the rest
// of the dialog's lifetime.
if (target.mode !== "edit" || ready) return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: editing a template twice reverts the first edit.

onSaved (line 405) only invalidates the listFrameTemplates key (lines 351-354). The getFrameTemplate row this dialog seeds from is left in the cache. On reopen, React Query hands back that cached row synchronously and refetches in the background. This effect resets the form from the old row and sets ready, and the guard here then ignores the fresh data when it arrives.

Sequence: edit template T, save. Click Edit on T again. The form shows the pre-edit content. Save writes the old values back over the edit you just made, with no warning.

The page tests don't catch it because each query is mocked with fixed data, so nothing is ever stale.

Fix: also invalidate the getFrameTemplate key for the saved id in onSaved. Alternatively give rowQ staleTime: 0 + refetchOnMount: "always" and gate this seed on !rowQ.isFetching so it waits for the fresh row.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, though not the way the first fix suggests: invalidating the getFrameTemplate key isn't enough on its own. React Query keeps invalidated data in the cache and only refetches active queries, so the reopened dialog is still handed the pre-edit row synchronously while the refetch runs behind it — and the ready latch then ignores the fresh row when it lands. I took the alternative you offered: refetchOnMount: "always" plus gating the seed on isFetchedAfterMount.

That guard alone was still wrong. A failed fetch also satisfies isFetchedAfterMount (query-core counts errorUpdateCount alongside dataUpdateCount), and its error reducer keeps the cached data rather than clearing it — so a forced refetch that failed over an already-cached row seeded from that row and then latched, hiding the error. Both effects now refuse to seed while error is set. The rule is one function, freshData() in web/src/lib/query-freshness.ts, with the library reasoning in one place.

The authoring page seeded the same way from the same RPC, so your bug was reachable across pages too (admin edits a template, author creates from it in the same session) — same fix, and both surfaces have a stale-seed regression test that I checked fails without the guard.

// once at creation, so applying one to a Frame that already exists is a
// mistake rather than an update, and the existing create-intent branch
// below reports it as AlreadyExists with a message that says so.
intent = PublishCreate

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name collision on create now lands under the version field in the web app.

Every web create publish carries a template id (even "Blank" is builtin:blank), so every web create is now PublishCreate and a name that already exists returns AlreadyExists. web/src/lib/publish-errors.ts:14 unconditionally maps AlreadyExists onto the version field, and FrameAuthoringPage.tsx:332 keeps the publish dialog open whenever version has an error. The author sees "a frame named X already exists; update it instead" under the version input, and changing the version retries into the same error.

This is better than the pre-PR behaviour (a silent upsert onto someone else's Frame), it just stops one step short. Fix on either side: attach a FieldViolation for name here, or have mapPublishError route a frame-name AlreadyExists to name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed server-side. nameTakenErr now attaches a FieldViolations detail naming name, and mapPublishError honours a named field on AlreadyExists, keeping version as the fallback for a bare republish. Since name lives in the form and version in the publish dialog, the dialog closes and the message lands on the input that can actually fix it.

I also recorded it in the contract: the FieldViolations proto comment said the detail was a PublishFrame-schema-validation thing, and PublishDoc's error list bound field violations to InvalidArgument. A client author reading either would route every AlreadyExists to the version input — the same bug, one layer up. Both amended, gen/ regenerated.

Comment thread cli/cmd/template.go Outdated
fmt.Fprintf(&b, "# %s\n", t.Description)
}
b.WriteString("#\n# Fill in name, description, and version, then publish with:\n")
b.WriteString("# frames publish --dir . --template " + t.Id + "\n")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header documents a command that fails on the second publish.

--template now forces create intent (service.go:125), so frames publish --dir . --template <id> works for 1.0.0 and returns AlreadyExists ("update it instead") for every version after. publish.go:30-35 only special-cases InvalidArgument, so the user sees a bare already_exists: error with no hint to drop the flag. And because the scaffold comment persists into stored content by design, the wrong instruction stays in the file forever.

Fix: word this as "publish the first version with" (and say later versions omit --template), and add an AlreadyExists branch in publish.go that suggests dropping the flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both halves. The header now names the first-version command and the bare one for every version after, in two lines rather than six, since these comments persist into stored content. frames publish explains an AlreadyExists when --template was passed, and says nothing about the flag when it wasn't — no pointing at a command the author never ran.

One thing beyond what you flagged: the flag's own help text never said it asserts a new Frame, which is the first place someone looks. It does now, and docs/site/.../frames_publish.md is regenerated.

);
// Fetched only once a template is chosen. Disabled otherwise so the picker
// screen does not issue a pointless request.
const chosen = useQuery(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Template query failures are swallowed in the create flow.

Neither chosen.error nor templates.error is read anywhere in this component.

  • A failed listFrameTemplates renders TemplatePicker with [] (line 395), which draws the heading and intro text and no cards. I hit exactly this today against a dev DB missing the frame_templates table: a blank picker with no explanation.
  • A failed getFrameTemplate (deleted org template, foreign org id, typo in ?template=) renders a blank form, but publishTemplateID still sends the bad id, so every publish fails with "unknown template_id" and the only way out is hand-editing the URL.

Fix: render templates.error inside the picker, and on chosen.error show an alert with a "choose a different template" action that clears the template search param.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. TemplatePicker takes failed/onRetry and renders the failure instead of drawing a heading over an empty catalog. A failed getFrameTemplate is now a dead end with a retry and a "choose a different template" that clears the id from the URL, rather than a blank form whose every publish is refused. I also narrowed the fetch to publishTemplateID(...) !== "", so the fetch and the publish can't disagree about whether the template matters.

One deliberate difference from your description: the picker states a fixed sentence rather than the server's text, matching the eight other read failures in the app. Your case is why — no such table: frame_templates is storage detail an author can't act on, and the alert plus retry answers "why is this empty?" without it. Push back if you'd rather see the raw message on these screens; it's a one-line change and I'd rather the app be consistent either way.

Comment thread web/src/pages/AdminTemplatesPage.tsx Outdated
const templateFormSchema = z.object({
title: z.string().trim().min(1, "must not be empty"),
description: z.string().trim().min(1, "must not be empty"),
slots: slotsSchema,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank list rows persist into org template prefill and break the CLI scaffold.

slotsSchema allows empty strings in rules / skills / prompts, and compactSlots (frame-yaml.ts:62) keeps any non-empty array, blanks included. On the server, ParsePrefill (templates.go:207) only calls Parse, never Validate, so rules: [""] is stored as-is.

frames template init then splices the prefill verbatim into frame.yaml, and frames publish fails with slots.rules[0]: must not be empty for a row the author never wrote.

Fix: drop blank list entries in compactSlots, and run the list-item checks from Validate inside ParsePrefill so a bad blob is refused at save time rather than at the author's first publish.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, but not inside ParsePrefill. That decoder is also the read path — rowToTemplate runs every stored row through it — so a content rule there would make a template already saved with a blank row unreadable (GetFrameTemplate and the post-save read both fail) instead of merely unpublishable. Strictly worse than the bug.

Instead the checks are shared out of Validate as contentErrors and run at the write boundary, in validateTemplateInput, covering create and update (an update replaces the row wholesale, so a create-only rule is one an update walks past). TestParsePrefill now pins the decoder's leniency on purpose so nobody "fixes" it later. The refusal goes through violationErr like every other ValidationError crossing that boundary, so the field paths reach clients as a detail.

Client side, compactSlots drops blank list rows and fully-empty terminology rows (a half-filled row survives — that's a real error the author meant to write). The template dialog was also validating with frame-yaml's lenient decode schema rather than the authoring form's rules, so an incomplete row still reached the server; both forms now resolve against contentSlotsSchema.

…ot be read

Saving a template and reopening Edit seeded the dialog from the row cached
before the save, so a second Save wrote the pre-edit content back over the
first edit with no warning.

Invalidating the row key would not have fixed it: React Query keeps
invalidated data in the cache and refetches only active queries, so the
reopened dialog is still handed the stale row synchronously while the
refetch runs behind it, and the `ready` latch then ignores the fresh row
when it lands. The seed instead waits for a fetch this mount performed
(`isFetchedAfterMount`, which exists for exactly this), with
`refetchOnMount: "always"` so there is always such a fetch to wait for.

The authoring page seeded the same way from the same RPC, so an admin's
edit was invisible to a create started from that template in the same
session. It gets the same guard.

Waiting on a fetch makes a failed one matter, and neither page reported
those:

- A failed listFrameTemplates drew the picker's heading and no cards,
  which reads as "your org has no templates" - never true, since the
  built-ins are compiled in. TemplatePicker now takes the error and a
  retry.
- A failed getFrameTemplate rendered a blank form that could never
  publish: the id stays in the URL and every publish carries it, so the
  server refuses each one. It now offers retry and "choose a different
  template", which clears the id.
- In the edit dialog, both a failed fetch and a prefill that will not
  decode left the loading skeleton up forever - the decode failure set
  `formError`, which only renders inside the form it never reached.

The template fetch is also narrowed to the flow that sends its id, so
publishTemplateID decides both and the fetch cannot disagree with the
publish about whether the template matters.
Every web create publish carries a template id (Blank is builtin:blank),
so every web create now asserts create intent, and a name that already
exists comes back as AlreadyExists. The web app mapped that code
unconditionally onto `version`, so the author read "a frame named X
already exists; update it instead" under the version input - and changing
the version retried straight into the same error.

A republished version uses the same code, and the two are fixed in
different inputs, so the code alone cannot route them. The server now
attaches a FieldViolations detail naming `name` (connect-go allows details
on any error), and the client honors a named field when there is one,
keeping `version` as the default for a bare republish. Since `name` lives
in the form and `version` in the publish dialog, the dialog now closes and
the message appears where the fix is.
The scaffold's header told an author to publish with
`--template <id>`. That flag asserts a new Frame, so it works for 1.0.0
and returns AlreadyExists for every version after - and since the header
persists into the published content by design, the wrong instruction
stayed in the file for good.

The header now names the first-version command and the bare command for
every later one. `frames publish` also explains the refusal when the flag
is what caused it, rather than printing a bare already_exists; it stays
quiet about the flag when the author never passed one, so nothing points
at a command they did not run.
A list editor leaves an empty row behind the moment an author adds one and
thinks better of it, and nothing stopped one from being stored in a
template's prefill: the web serializer kept any non-empty array, blanks
included, and the server never checked a prefill's content. `frames
template init` then spliced it verbatim into frame.yaml and `frames
publish` failed with "slots.rules[0]: must not be empty" - naming a row
some other author never wrote.

The serializer drops blank list rows, for frames and prefills alike, and
the content checks are split out of Validate so a prefill is held to the
same rules a published Frame is.

Those checks run at the write boundary rather than inside ParsePrefill.
ParsePrefill is the read path too - rowToTemplate decodes every stored row
through it - so refusing a blank row there would make a template already
saved with one unreadable, failing GetFrameTemplate and the post-save read
instead of merely failing a publish. TestParsePrefill now pins that
leniency deliberately, and the write test covers create and update, since
an update replaces the stored row wholesale.
Two defects the fixes introduced, one they left half done, and the contract
they changed without saying so.

- A later query failure replaced a form the author was already typing into.
  Both error screens gated on error-truthiness alone, so a failed refetch -
  reconnect, a token refresh, a delete in another session - evicted unsaved
  work to report a template that had demonstrably just loaded. They now
  render only while there is nothing to show.

- Blank terminology rows still reached the server. `compactSlots` dropped
  abandoned rows for rules/skills/prompts but passed terminology through
  whole, and the template dialog validates with the loose slot schema rather
  than the authoring form's, so "+ Add term" then thinking better of it hit
  exactly the publish failure this branch exists to prevent. A row with one
  side filled still survives: that is a real error the author meant to write.

- `FieldViolations` is no longer only an InvalidArgument detail, so the proto
  comment and `PublishDoc`'s error list now say where else it is attached and
  what its absence means. A client author reading the old enumeration would
  route every AlreadyExists to the version input, which is the bug the
  previous commit fixed in the web app.

- The prefill check now goes through `violationErr` like every other
  ValidationError crossing that boundary, so its field paths reach clients as
  a detail rather than only inside a flattened message.

Also: the authoring page's seed guard had no test - deleting it broke
nothing - and was not paired with `refetchOnMount: "always"`, so it passed
only because no global staleTime is set. Its latch is now per template rather
than per mount, so returning to the picker cannot leave the first template's
content in a form publishing under the second one's id. Read failures state a
fixed sentence, matching every other read failure in the app rather than
putting storage detail on screen. `loadBuiltins` runs the content check too,
so the rule guards both doors starters arrive through. And `--template`'s own
help says it asserts a new Frame.
…lot forms to one content schema

A failed fetch satisfies `isFetchedAfterMount` - query-core counts data
updates and error updates alike - and its error reducer keeps whatever was
already cached, flagging it invalidated rather than clearing it. So a forced
refetch that failed over an already-cached row still looked, to both seed
effects, exactly like fresh data arriving: they seeded from the stale row,
latched, and suppressed the error for the rest of the session. That is the
bug this mechanism exists to refuse, reached through the error path instead
of the cache path. Both effects now refuse to seed while `error` is set.

The dialog's "returned no content" branch was reached by that same path, so
it also overrode the accurate message on every ordinary failure - a deleted
template reported as empty content. It now speaks only about a fetch that
actually succeeded, and its test asserts which sentence appears rather than
only that the old one does not.

The template dialog also validated slot content with the decode schema
rather than the authoring rules, so an admin could save an incomplete
terminology row. Serialization drops rows that are entirely empty, so a
violation the server reported could name an index that no longer matched the
form. The strict rules move to `contentSlotsSchema`, both forms resolve
against it, and the decode schema is unexported with the split written down:
one shape parses what is stored, the other governs what a human may submit -
the same division `Parse` and `contentErrors` make on the server.

AGENTS.md records the three invariants this round had to re-derive: the four
options that make a latching seed correct, the two-schema split, and read
versus write error text.
…e once

An array-level rule reaches react-hook-form nested under `<path>.root`
whenever that path has registered children - @hookform/resolvers does that
in toNestErrors - and the field reader only looked at `.message`. So the
duplicate-term refine produced an error that rendered nowhere and a form
that refused to submit: a Save button that did nothing at all. Sharing the
authoring content rules with the template dialog last commit extended that
silence to a second surface, where duplicates had simply saved before.
Reading the nested message fixes both, including the authoring form, where
it has been silent since the refine was written.

The rest is consolidation of three rounds of patches:

- `freshData()` names the rule both seed effects were open-coding, with the
  library reasoning in one place instead of the same eight comment lines in
  two files. Both effects are now two lines and read as one intent.
- A prefill that will not decode no longer latches as seeded. It seeded
  nothing, so calling it seeded left the author on a defaults-only form with
  the id still in the URL and still sent by every publish - required slots
  checked against content they never saw. It is a dead end with the same two
  ways out as a failed fetch.
- The edit dialog is keyed per target, so "mounted per target" is structural
  rather than a consequence of the dialog being modal. That is what makes a
  boolean latch correct there, and AGENTS.md now says which latch belongs
  where instead of asserting one rule for both.
- The dialog's "returned no content" branch is gone: `GetFrameTemplate`
  cannot produce that state, no test exercised it, and the error alert
  already covers every failure that is reachable.
- The picker no longer blames the registry for being unreachable. A client
  cannot tell that from a registry that answered with an error, and the case
  that prompted the finding - a database missing the templates table - is the
  second.
- The built-ins content check was a call that could never fire. Replaced with
  a reflective test in the idiom internal/mcp already uses, which fails with
  instructions the day `builtinFile` gains a prefill key.
- The scaffold header carries the same two commands in three fewer lines,
  since these comments persist into published content by design.
…rationales

`refetchOnMount: "always"` guarantees a template's prefill arrives a round
trip after the first render, and `reset` replaces form state wholesale - so
the create form was editable for exactly as long as it took to discard
anything typed into it. It now withholds the form the way the edit dialog
already did. The test harnesses that render "past the picker" say what that
means in the app: a template that was chosen and fetched.

Three comments claimed constraints the code does not have, which is the same
defect the previous round corrected in AGENTS.md, one level down:

- Two `setTimeout(…, 0)` wrappers cited react-hooks/set-state-in-effect. The
  rule reports at most one violation per effect and an earlier disable had
  already spent it in both, so neither deferral was doing anything; verified
  by unwrapping them and running the linter. AGENTS.md records the trap,
  since the linter's silence is what misled.
- The seed effect spelled the "does this template matter" condition itself
  while the fetch and every publish read `publishTemplateID`, which the
  surrounding comment claims cannot happen.
- "Try again" was offered for a prefill that will not decode, where the
  stored bytes are the input and a retry redraws the same screen.

Both effects now depend on the row they use rather than on the three query
fields `freshData` reads, so adding a condition to that helper cannot leave
a hand-maintained dependency array silently under-specified.
The web dialog treats "a row I do not have" as "still fetching", so a
GetFrameTemplate success carrying no template would strand it on a loading
skeleton with nothing to report. Deleting its defensive branch was right -
the RPC cannot produce that - but nothing said so or held it there. Now the
method documents it and a test asserts it across every id shape a caller can
send, including the ones that error.

The retry offered on a failed template load was also unpinned: removing the
button entirely, or offering it for a prefill that will not decode, both left
the suite green. Its presence and its absence are now asserted where each
belongs.
…cised

The five-row table added for it asserted nothing on three of its rows: an id
that errors satisfies "if success, then non-nil" trivially, and the error
codes for those three shapes are already pinned exactly by
TestGetFrameTemplate. Under a mutation that empties only the success path,
those rows passed regardless.

Folded into that test's success cases instead, where it fails with a message
rather than the nil dereference the existing field reads would have produced,
and the doc comment now claims only what is checked.
@dcmcand

dcmcand commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed fixes for all five. frame-templates was force-pushed to strip commit trailers, so the SHAs changed — your comments anchor to 721845f, which is untouched, so the threads still line up.

I reviewed the fixes rather than trusting them, and that turned up more than the original five. Worth knowing about, in rough order of severity:

  • The stale-seed fix reintroduced your blocker through the error path: a failed fetch satisfies isFetchedAfterMount and query-core keeps cached data on error, so the guard happily seeded a stale row. Fixed and pinned per surface.
  • An array-level zod refine (the duplicate-term rule) lands under <path>.root in react-hook-form, and useFieldError only read .message — so a duplicate term produced a Save button that silently did nothing. That predated this branch on the authoring form; sharing the content schema surfaced it on the template dialog too.
  • The new error screens replaced an in-progress form on any later failure, throwing away unsaved work to report a template that had just loaded fine. They now render only while there's nothing to show.
  • A prefill that wouldn't decode latched as "seeded", leaving the author on a defaults-only form still publishing under that template id.

AGENTS.md records the four invariants this took: the options that make a latching seed correct, the two-schema split (lenient decode vs. what a human may submit), read-vs-write error text, and where prefill content rules live.

Open question for you: the three new failure screens are Alerts with retry actions, while the app's eight existing read failures are one bare sentence. I didn't converge them — which direction do you want?

@dcmcand
dcmcand requested a review from jbouder September 10, 2026 07:45
@dcmcand
dcmcand merged commit 0122b5a into main Sep 10, 2026
6 checks passed
@dcmcand
dcmcand deleted the frame-templates branch September 10, 2026 11:07
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.

Frame templates: start a new Frame from a starter template

2 participants