Skip to content

docs(spec): scope the email-template locale-floor claims to a call that names a locale - #18482

Draft
os-warren wants to merge 4 commits into
mainfrom
claude/issue-18056-email-template-locale-rungs
Draft

os-warren wants to merge 4 commits into
mainfrom
claude/issue-18056-email-template-locale-rungs

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18056

packages/spec stated two different rung counts for one resolution. This
decides which text is wrong by measurement against the runtime, not by which
was easier to edit, and pins the floor guard's two silent early-returns without
changing what it warns about.

Clause-②: yes — the card's claim comment (5698961929) is the carrier; this
body restates it for legibility. The changeset is minor, which that declaration
requires, and the diff genuinely ships (measured below).

Remediation round (text-only, no logic moved)

An isolated at-tier contract review of head 708595dc9c returned FAIL on the
completeness of the surrounding claims, not on the work: the three-rung reading, the
untouched docs page and the Half B ablation all verified. 642da7fda5 closes the five
required items, every one of them text:

  1. .changeset/email-template-locale-floor.md — the pending, unreleased note from
    fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884 (a61ae59f93, 2026-09-12: the only commit that has ever touched that file, and
    its --diff-filter=A add) still said "retries exactly one rung — the literal
    en-US"
    and "the resolver's sole retry rung", unscoped. changeset version would
    have compiled both verbatim into the published packages/spec/CHANGELOG.md — which
    that package ships in its files[] — beside this PR's correction of them: the
    erratum-in-a-later-entry form AGENTS.md:686 forbids. Both are now scoped to a call
    that NAMES a locale, with a pointer to SendTemplateInput.locale for the full ladder;
    the same file's "the single literal en-US rung" is scoped for the same reason.
  2. packages/spec/src/stack-email-template-locale-floor.test.ts:12-14 — header sentence
    scoped, with the no-locale case named beside it. Its title line's bare "no fallback
    floor"
    is scoped the same way.
  3. packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33"its
    single retry rung"
    now names both rungs and which call shape reaches each.
  4. The changeset and this body now state that the guard's warning TEXT changed — see
    Half B, where "byte-for-byte" is gone and "control flow unchanged" stays.
  5. The acceptance note on sys-email-template.object.ts is corrected — see Acceptance
    notes.

Check Changeset is RED on this head by design, and must not be turned green. The
gate refuses because .changeset/email-template-locale-floor.md exists on the merge base
and was not added by this PR. Item 1 is its DELIBERATE CORRECTION arm, ⛔ not its
COLLISION arm — so the gate's own step 1, restoring that file from the base, is the one
thing not to do here: it would republish the sentence this PR proves false. Restoring it,
renaming the edit into a second changeset, and labelling around it were each considered
and rejected; the middle one is the erratum shape, and skip-changeset suppressing this
very refusal is itself an open finding (#18375). The blob is byte-identical on the merge
base and on origin/main (13881152ee), so no concurrent note is being clobbered. The
gate stays red until a human confirms the correction; that confirmation is the seat's and
is requested in its own comment.

No logic line moved — measured, not asserted. Both .ts files were reprinted through
the TypeScript printer with removeComments and hashed: stripped sha256 c026b27d… and
98e79f85…, byte-identical before and after, while both raw file hashes changed. Dark
control on a pair that really does move code (8fe5cb8e51..708595dc9c on stack.zod.ts)
reads DIFFERENT, so the instrument is not blind. Independently: all 24 changed lines in
those two files match a comment-line shape, and the same filter over that known-logic diff
finds 12 non-comment lines.

Half A — which text is wrong

Read against EmailService.resolveAndRenderTemplate and
createSysEmailTemplateLoader in @objectstack/plugin-email:

rung condition outcome
1 the named locale, matched exactly that row; no language-subtag folding
2 the literal en-US — also where a call naming no locale starts that row
3 only when the call named no locale, and the bundle has no en-US row the bundle's lowest locale tag, rendered silently

A call that names a locale never reaches rung 3: it dead-letters with
TEMPLATE_NOT_FOUND (permanent). A call that names none never dead-letters
on a non-empty bundle.

Verdict: contracts/email-service.ts was right and system/email-template.zod.ts
was wrong.
Four independent statements agree with the code, one did not:

  • SendTemplateInput.locale (contracts/email-service.ts) — three rungs, correct.
  • content/docs/automation/email-templates.mdx — three rungs, correct, including
    "A call that names a locale with no exact row and no en-US row fails with
    TEMPLATE_NOT_FOUND".
  • examples/app-showcase/src/system/emails/index.ts — "exact match → en-US
    (no-locale calls only) the bundle's lowest tag".
  • ✅ The CI pin plugin-email/src/template-locale-resolution.test.ts — rung 3
    resolving for a no-locale call, and explicitly not widened for a named one
    ("an explicit locale is NOT widened to 'any row' when neither it nor en-US exists").
  • EmailTemplateDefinitionSchema.locale + EMAIL_TEMPLATE_FLOOR_LOCALE — one
    rung and "no fallback floor at all".

So the published declaration promised a loud permanent refusal on exactly the
path where the runtime performs a silent fill. Fixed text-only, in-fence: inside
packages/spec
every floor claim is now scoped to "a call that NAMES a locale", the
no-locale rung is stated beside it, and the ladder itself is stated in one place only.
Carriers outside that package still state the old claim — they are named under
Acceptance notes and deliberately not touched here.

Also corrected in the same fence, and declared rather than smuggled: the
SendTemplateInput.template TSDoc said the service "picks the best-matching locale
row". Measured — there is no best match and no folding anywhere in the resolver, so
that sentence described a behaviour this package has never had. Same defect class,
same declared file surface, same gate family.

And en/en-US were called "different bundles" one paragraph after the TSDoc
defines a bundle as rows sharing one name. They are different rows of one
bundle; the rewritten sentence says so.

Both sides of the generated projection, all four numbers

That describe projects into content/docs/references/system/email-template.mdx.
A source-only fix would have left the old sentence on the reference page:

old spelling new spelling
SOURCE packages/spec/src/system/email-template.zod.ts 0 1
GENERATED content/docs/references/system/email-template.mdx 0 1

Control: BCP-47 locale still returns 1 in the generated file, so the zero above is
a real absence and not an unreadable path. Before gen:docs the generated column read
1 / 0 — the trap, caught and closed. check:generated now reports all 15 artifacts
up to date.

It ships, measured on the built artifact

packages/spec publishes dist and src/**/*.zod.ts. In the built tree: 16
files carry the new describe string, 16 carry the new TSDoc scope, 0 carry
the old spelling; positive control BCP-47 locale = 38 files. Hence the changeset,
and hence minor.

Half B — declared, pinned, and NOT flipped

warnEmailTemplateLocaleFloor's control flow is unchanged — the same bundles warn,
once each, and the warning stays advisory. What it gains is a declaration of the two
shapes it does not examine, and pins that hold both.

⚠️ Its emitted warning TEXT did change, and now names BOTH call shapes (5 concatenated
lines → 7): it says the bundle has no fallback floor for a send that names a locale, and
adds that a send naming no locale does not fail but drops to that bundle's lowest tag
and renders it silently. The changeset says the same. An earlier revision of this body
called the guard unchanged "byte-for-byte" — true of the control flow, false of the
string — so that phrase is gone.

Measured, and sharper than the card had it: early return 1 decides nothing on its
own.
supportedLocales is REQUIRED inside i18n, so its absent arm is reachable
only via a stack with no i18n block; and with no supported set every bundle's
declared list is empty, so early return 2 skips exactly the same shapes. What
return 1 actually buys is not reading .map off undefined — deleting it takes
defineStack down with a TypeError, which the new pin asserts.

Blast radius, both directions, with a live control. In-tree stacks declaring
emailTemplates: two — examples/app-showcase (real i18n block parsed from its own
config, real allEmails module) and packages/qa/dogfood/.../email-template-materialization-fixture.ts
(whole stack). Both carry an en-US row, so both hit the floor check before either
early return is reached.

The enforce-or-remove question (ADR-0049) is not answered here; it is recorded in
the docblock and reported to the seat.

Ablation — the pins bite, proven on disk

Every leg mutated the tree, proved the mutation by anchor count before any result
was read, and restored by blob hash plus an empty git diff HEAD. Run from the
committed state, under a trap ... EXIT INT TERM with absolute paths.

leg mutation (proven on disk) result
0 none, clean c50c54efae 14 passed
1 delete early return 1 (anchor 1 → 0, blob 7ba260b6 vs HEAD a9f45392) 3 failed / 11 passed
2 delete early return 2 (anchor 1 → 0, blob 4c24ea22 vs HEAD a9f45392) 2 failed / 12 passed
3 put the shipped unscoped describe back (NAMES a locale 2 → 1, blob 0c068b72 vs HEAD 915c2e31) 1 failed / 13 passed

Restore verified each time: RESTORED-OK … blob == HEAD, final
GIT_DIFF_HEAD_EMPTY=true. No ablation artefact is left in the tree.

Verification

  • pnpm --filter @objectstack/spec test483 files, 13777 tests passed
  • pnpm --filter @objectstack/spec typecheck — clean
  • pnpm --filter @objectstack/spec check:generatedall 15 artifacts up to date
  • eslint . --no-inline-config over the full repo population: 6796 files, 0
    errors, 0 warnings, exit 0
    — run at final commit 708595dc9c, so no narrowing
    claim is needed
  • Gate families derived from the real change set by scripts/pm/dispatch-gates.mjs
    (never a hand-written path list): 109 derived, 107 run green, 2 NOT MEASURED
    (check:dual-build-cjs-loads, check:type-check-debt — both exit 3
    PREREQUISITE NOT MET, an unbuilt workspace closure, which CI builds fresh)
  • One gate red, proven not ours by a two-leg control on a pristine origin/main
    worktree: check:cross-package-test-inputs. Leg A (all six of this PR's paths, no
    packages/spec/dist/) → exit 0. Leg B (zero of this PR's paths, plus an empty
    packages/spec/dist/) → exit 1, identical finding. It reds on the presence of a
    local spec build, not on this diff. Already filed as [finding] check:cross-package-test-inputs answers 1 or 0 depending on whether packages/spec has been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440 (open,
    2026-09-16T10:52Z) — ⛔ not re-filed here. Re-measured on this round's worktree with no
    packages/spec/dist present: exit 0, the same pre-build leg.

This round (642da7fda5)

  • pnpm --filter @objectstack/spec exec vitest run over the two edited test files —
    2 files, 17 tests passed, exit 0 (under the shared verify lock, VERDICT command-exit 0)
  • pnpm --filter @objectstack/spec typecheck — exit 0
  • Green, each exit code captured by redirect-then-$? and never through a pipe:
    check:nul-bytes, check:changeset-no-major, check:adr-0087-registration,
    check:comment-mask-adoption, check:comment-mask-corpus,
    check:spec-docblock-symbol-anchors, check:keyed-text-bounds,
    check:test-source-alias, check:pm-widening-tells, check:objectui-changeset,
    check:pm-changeset-deadline-census, check:closing-keyword-parity,
    check:cross-package-test-inputs
  • check:empty-changesetexit 1, RED BY DESIGN (DELIBERATE CORRECTION arm, above).
    Its own --self-test passes, 159 assertions, so the instrument is sound.
  • The full CI farm on this head is left to CI and is in progress, not assumed green.

Acceptance notes

Noted, not filed — each with who would meet it:

  • packages/spec/src/system/email-template.form.ts says nothing about the floor, so the
    Studio authoring path teaches none of the above. A gap, not an error. Successor: the
    next card touching the email-template authoring form.
  • ⚠️ Correction to an earlier revision of this note.
    packages/platform-objects/src/audit/sys-email-template.object.ts:10-11 does not
    "say nothing about the floor". Measured on this tree, it carries the false sentence
    "Resolved by (name, locale); the EmailService picks the best-matching locale for the
    recipient, falling back to en-US"
    — verbatim the third of the three false declarations
    that packages/plugins/plugin-email/src/template-loader.ts:20-22 already names. The same
    claim lives at
    packages/services/service-messaging/src/objects/notification-template.object.ts:65
    ("both resolve a template by best-matching locale") and at
    docs/qa/platform-checklist/areas/integration-system.json:818 ("(name, locale)
    resolution picks the best locale row and falls back to en-US"). There is no best match
    and no language-subtag folding anywhere in the resolver, so all three are false, not
    merely silent. Out of this card's declared surface — named here for a successor and
    deliberately NOT fixed in this PR.
    Successor: a platform-objects / service-messaging
    stale-carrier card, which the seat holds the finding for.
  • packages/metadata-core/src/item-key-discriminators.ts quotes
    email-template.zod.ts as saying the service "picks the best match for the
    recipient's locale" — measured 0 hits there (controls i18n bundle = 1,
    must stay equal = 1). The sentence lives, in a variant, in
    contracts/email-service.ts, and this PR corrects that variant, so the quotation is
    now doubly stale. Successor: the next card on email-template identity keying.
  • A cross-package pin holding the spec's published describe equal to the runtime's
    ladder would close this class mechanically, in the shape
    email-template-floor-locale-parity.pin.test.ts already uses for the constant.
    Successor: none today — offered to the seat as a follow-up.

Generated by Claude Code


Generated by Claude Code

…ocale call

`email-template.zod.ts` published one rung and "no fallback floor at all";
`contracts/email-service.ts` documents a three-rung ladder whose third rung the
runtime and its CI pin both perform. Measured against
`EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader`: the
three-rung text is correct and the one-rung text is over-broad, so the floor
claims are scoped to a call that names a locale and the no-locale rung is
stated beside them.

Also declares what `warnEmailTemplateLocaleFloor` deliberately does not
examine, and corrects the "best-matching locale row" wording the resolver has
never implemented.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
…oundaries

The two shapes `warnEmailTemplateLocaleFloor` returns early on were filed under
a describe titled "stays silent where the bundle HAS a floor" — false for both:
each bundle is floorless and silent because it is outside the guard's scope.
Splits them into their own block, pairs every silent case with a warning
discriminator so silence cannot be read out of a dead harness, and pins that
early return 1 also guards the `supported.map` read.

Adds a pin holding the published `locale` describe to naming both call shapes.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
…changeset

`gen:docs` projects `EmailTemplateDefinitionSchema.locale`'s describe into
content/docs/references/system/email-template.mdx, so the source correction
without this regeneration would have shipped the old sentence to every reader
of the reference page.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/email-templates.mdx (via EmailTemplateDefinitionSchema (symbol, a top-level const))
  • content/docs/kernel/runtime-services/email-service.mdx (via SendTemplateInput (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via SendTemplateInput (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json bf61f0a196f8881a825e36793715ebb002c241a3packageMentionDocs.

Which tree this was computed on

This run read content/docs from a8156c753af3903bf2095ed0bcfd45b0cb0874f5 — the merge of head 642da7fda54c4a36f7178367fa902ba12430a9d8 into base bf61f0a196f8881a825e36793715ebb002c241a3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a8156c753af3903bf2095ed0bcfd45b0cb0874f5 && git checkout a8156c753af3903bf2095ed0bcfd45b0cb0874f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf61f0a196f8881a825e36793715ebb002c241a3 642da7fda54c4a36f7178367fa902ba12430a9d8 && git checkout -B drift-repro bf61f0a196f8881a825e36793715ebb002c241a3 && git merge --no-ff 642da7fda54c4a36f7178367fa902ba12430a9d8

node scripts/docs-audit/affected-docs.mjs --json bf61f0a196f8881a825e36793715ebb002c241a3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs bf61f0a196f8881a825e36793715ebb002c241a3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 708595dc9c453142211eba1a763de4c976da16e8

① Derived judgments

  1. Which text is false — CORRECT. Read at head off EmailService.resolveAndRenderTemplate (email-service.ts:1316-1329) and createSysEmailTemplateLoader (template-loader.ts:111-117): exact tag, then the literal en-US, then — only when preferred is falsy and the bundle has no en-US row — the loader's lowest-tag answer. contracts/email-service.ts three-rung text is true; the shipped email-template.zod.ts one-rung / "no fallback floor at all" text was false for no-locale calls. Every rewritten sentence in the diff agrees with the code and with the CI pin (template-locale-resolution.test.ts:155-168 resolving, :215-222 not widened).

  2. Published surfaces at head — CLEAN. Packed tarball (2014 files): new describe in 16 dist + 6 json-schema + 1 src; old phrase 0, "no fallback floor at all" 0, "sole rung" 0, "different bundles" 0; control BCP-47 locale 64 files. Generated reference page regenerated: base gen old 1 / new 0 to head gen old 0 / new 1, source new 0 to 1, control 1 both; check:generated 15/15 up to date post-build. The round's "SOURCE old spelling 0" is not a discriminating reading (0 at base too; phrase spans two concatenated literals) — the source-side movers are "different bundles" 1 to 0 and "no fallback floor at all" 1 to 0.

  3. Docs page — CORRECT and rightly untouched. Line-grep 0, grep -z 1, single match spanning lines 135-136; the named-locale refusal sentence sits at lines 139-140; controls light. The three-rung side has at least the four texts claimed (plus template-loader.ts and email-service.ts docblocks).

  4. Completeness — INCOMPLETE inside packages/spec. Three unscoped one-rung statements survive: (a) the pending, unreleased changeset .changeset/email-template-locale-floor.md ("retries exactly one rung — the literal en-US"; "the resolver's sole retry rung"), which the next release compiles verbatim into the PUBLISHED spec CHANGELOG.md beside this PR's correction — the erratum-in-a-later-entry form AGENTS.md line 686 forbids, with 12+ precedent commits for amending a pending changeset; (b) stack-email-template-locale-floor.test.ts:12-14, a file this PR edits; (c) email-template-floor-locale-parity.pin.test.ts:33 ("single retry rung"). The PR body's "every floor claim is scoped" and "the ladder is stated in one place only" are therefore overstated.

  5. Half B — logic unchanged, pins bite, correction sound. Control flow byte-identical (function body with the warn string stripped diffs empty); only the emitted warning TEXT changed (5 to 7 lines), which neither the changeset nor the body discloses in prose. Ablation re-run from a proven mutation: leg 1 = 3 failed, leg 2 = 2 failed, leg 3 = 1 failed; base pins against head source 10/10. supportedLocales is REQUIRED inside i18n (probe threw); no-i18n and [] parse silently; the trap control warns once. The two moved pins were floorless bundles under a title claiming a floor — the round's correction is right.

Required changes for PASS (text-only, no logic):

  • Amend .changeset/email-template-locale-floor.md: scope "retries exactly one rung — the literal en-US" to a call that names a locale and replace "the resolver's sole retry rung" with the scoped form or a pointer to SendTemplateInput.locale.
  • Scope the header sentence at packages/spec/src/stack-email-template-locale-floor.test.ts:12-14.
  • Scope packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33 ("single retry rung").
  • State in the changeset (and body) that the guard's warning text now names both call shapes; keep "control flow unchanged", drop "byte-for-byte".
  • Correct the acceptance note: sys-email-template.object.ts:10-11 carries the false "picks the best-matching locale" sentence (the one template-loader.ts:20-22 quotes), as do notification-template.object.ts:65 and integration-system.json:818 — out of this card's surface, to be filed or named for a successor, not described as "says nothing".

② Semver level

minor is correct and required: AGENTS.md line 1043 binds a Clause-②: yes PR to at least minor. The declaration is readable on both carriers (check-clause2-carriers --pair 18482 exit 0). The diff genuinely ships, re-derived on the packed tarball at head, not the source: 23 files carry the new describe (dist 16, json-schema 6, src 1), 17 the new TSDoc scope, 2 d.ts carry the constant-docblock scope, 0 carry any old spelling; positive control BCP-47 locale = 64 files (dist js 32, d.ts 6). The round's 16 / 38 was a dist-only population; its conclusion holds.

③ Boundary flags

open_question (ADR-0049, early returns): re-derived, I concur with A now and B only as a separately ruled card; C is rejected. Reasoning of my own: the guard exists for the "consistent author" trap, which presupposes an i18n block, so a no-i18n stack is coherently out of its scope; a bundle whose tags all fall outside supportedLocales is a different authoring error (tags not in the supported set) and belongs to a different lint, not this floor check. Widening is a behaviour change on an authoring surface with zero in-tree blast radius measured (both emailTemplates stacks carry en-US), so it is a migration decision, not this text PR. Note ADR-0049 is a security-property gate cited here by repo convention as the general enforce-or-remove policy; the operative rule for this PR is Prime Directive 10 ("keep the claim as narrow as the enforcement"), which the declaration satisfies. Return 1 cannot be removed alone (TypeError, leg 1); any B must add a null-safe read.

out_of_scope_findings (4 in the report, not 5):

  • cross-package-test-inputs reds only with packages/spec/dist present: REPRODUCED at head (pre-build exit 0, post-build exit 1, rooted in packages/cli/test/init-created-files-summary.e2e.test.ts). Not this diff. Already filed as [finding] check:cross-package-test-inputs answers 1 or 0 depending on whether packages/spec has been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440 (open, 2026-09-16T10:52Z) — dedupe, do not re-file.
  • form.ts / sys-email-template.object.ts "silent on the floor": the form is; the object file is NOT silent — its docblock states the false best-matching claim. Escalate to the seat as a stale-carrier finding for platform-objects (published dist only; a source comment, so not shipped text, but the sentence the runtime doc names as false).
  • item-key-discriminators.ts quotation stale: confirmed (the quoted sentence has 0 hits in email-template.zod.ts, controls 1/1). Successor as noted.
  • cross-package pin for the ladder text: reasonable follow-up; the parity pin's shape exists and is registered. Not blocking.
  • Docs-drift bot flags: kernel email-service.mdx (locale 0 with -z, control SendTemplateInput 1) and releases/v17/17-3.mdx (names SendTemplateInput.org only) carry no ladder claim; no edit needed.

Implemented-by: claude/issue-18056-email-template-locale-rungs
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL


Generated by Claude Code

…a locale

Text-only remediation on the contract-review record for this PR. No logic line
moves: both .ts files reprint byte-identical through the TypeScript printer with
removeComments (stripped sha256 equal before and after), and a dark control on a
pair that really does move code reads DIFFERENT, so the instrument is not blind.

- .changeset/email-template-locale-floor.md (pending and unreleased, from
  #17884): "retries exactly one rung - the literal en-US" and "the resolver's
  sole retry rung" were unscoped, and would have compiled verbatim into the
  published packages/spec CHANGELOG.md beside this PR's correction of them --
  the erratum-in-a-later-entry form AGENTS.md forbids. Both are now scoped to a
  call that NAMES a locale, with a pointer to SendTemplateInput.locale for the
  full ladder. The same file's "the single literal en-US rung" is scoped too.
- stack-email-template-locale-floor.test.ts: the header sentence now scopes the
  one-rung claim and names the no-locale case; the title line's bare "no
  fallback floor" is scoped the same way.
- email-template-floor-locale-parity.pin.test.ts: "its single retry rung" now
  names both rungs and which call shape reaches each.
- .changeset/18056-email-template-locale-rungs.md: states that the guard's
  emitted warning TEXT changed and now names both call shapes, keeping "control
  flow is unchanged" and dropping any byte-for-byte claim.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>

Copy link
Copy Markdown
Collaborator Author

Check Changeset is RED BY DESIGN on head 642da7fda5 — the DELIBERATE CORRECTION arm, and this comment is the human path the gate names

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T16:1xZ. ⛔ Not a flake, ⛔ not re-run, ⛔ not routed around, and ⛔ the dev was told not to touch it.

The gate's own refusal, verbatim:

.changeset/email-template-locale-floor.md exists on the merge base and was not added by this PR, so changing or deleting it silently replaces somebody else's release note (#17712). … DELIBERATE CORRECTION — your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it — say so on the PR and get it confirmed; restoring it from the base would put the false sentence back.

Correcting a pending release note is a decision about a release rather than a refactor — say so on the PR, naming the note and what changed under it, and get it confirmed. That is the existing human path; this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it.

⇒ The gate is working. This comment names the note and what changed under it; the confirmation is the maintainer's and is being taken to them.

Naming the note, with its provenance measured on origin/main bf61f0a196 (16:10Z)

reading value
file .changeset/email-template-locale-floor.md
added by PR #17884, commit a61ae59f93 — a different, already-merged PR, ⛔ not this one
released yet? No. EMAIL_TEMPLATE_FLOOR_LOCALE in the published packages/spec/CHANGELOG.md = 0 (exit 1). Lit control BREAKING in the same file = 241, so the zero is a reading and not a broken grep

What changed under it

The note says, verbatim:

IEmailService.sendTemplate matches (name, locale) exactly and retries exactly one rung — the literal en-US.

New exported EMAIL_TEMPLATE_FLOOR_LOCALE names that tag once: it is both the schema default and the resolver's sole retry rung.

An isolated at-tier contract review of this PR (record 5700378995) re-derived the resolver at head and measured that unscoped, both sentences are false for a call that names no locale:

email-service.ts:1316-1329   preferred = input.locale && trim()
                             wanted = preferred || 'en-US'   → load(wanted)
                             → load('en-US') if wanted differs
                             → load(undefined) ONLY if (!row && !preferred)
template-loader.ts:111-117   undefined → (name,'en-US') else {name} ordered locale asc, id asc

⇒ there is a third rung for a no-locale call — the bundle's lowest locale tag — so «exactly one rung» and «sole retry rung» hold only for a call that names a locale. That is precisely what this PR corrects in the schema's own describe text.

Why leaving the note alone is the worse option

The note is pending, so at the next release changeset version compiles it verbatim into the published CHANGELOG.md — which packages/spec ships in its files[] — placing the false sentence in the published record beside this PR's correction of it. That is the erratum-in-a-later-entry form AGENTS.md:686 forbids.

Amending a pending changeset is ordinary practice in this repo, not an exception invented here: 12babac137 (#16856), a5d4e286b6 (#16874), 2cc4884d1f (#17851), and 12+ more.

The three things NOT done, each on purpose

⇒ What is being asked, and of whom

Maintainer decision, one word: may this PR scope PR #17884's pending, unreleased release note?

  • Yes (this seat's recommendation) — the two sentences gain «for a call that names a locale», nothing else in fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's note moves, and the published CHANGELOG stays true. The gate remains red on this one check, so landing also needs the seat's ③ 「every check green」 waived for it by name — ⛔ this seat does not waive it on its own.
  • No — the note ships as written, this PR drops that edit, and a follow-up card records that the published CHANGELOG will carry a sentence the same release falsifies.

Filed into this seat's decision batch. ⛔ The PR does not land until this is answered: a red Check Changeset is not covered by the merge-base exception (this failure does not reproduce on the merge base — it exists because of the merge base).

⚠️ For the record, so nothing here reads as settled: the contract review's verdict on this PR is FAIL with five owed text-only changes; the remediation round is in flight and this is change #1 of the five. The other four are ordinary and do not touch this gate.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 642da7fda54c4a36f7178367fa902ba12430a9d8

① Derived judgments

Whole-PR runtime delta, derived by comment-stripped ts.transpileModule over merge-base 8fe5cb8e51→head (my own instrument, distinct from the round's printer): contracts/email-service.ts transpiles byte-identical (comment-only change); system/email-template.zod.ts differs in exactly the locale .describe() literal (5→7 concatenated lines); stack.zod.ts differs in exactly the console.warn literal (5→7). No schema shape, key, default or export moves — check:generated 15/15 up to date on a fresh build of head, and CI TypeScript Type Check (which carries check:authorable-surface/check:api-surface/check:docs) is success on this head. So the accept set does not move; the public surface changes are text, one by one:

  1. EmailTemplateDefinitionSchema.locale .describe() — RIGHT. Ships as json-schema description (6 files), dist (16), src (1) on the packed tarball. Read against EmailService.resolveAndRenderTemplate (plugin-email/src/email-service.ts:1317-1333): preferred = locale && trim(), wanted = preferred || 'en-US', retry en-US only if wanted !== 'en-US', load(undefined) only if !row && !preferred; loader rung 3 = orderBy locale asc, id asc (template-loader.ts:74-78). "A call that NAMES a locale gets exactly one retry rung, the literal en-US" / "a bundle with no en-US row has no fallback floor for those calls" / "A call naming NO locale ... drops to the bundle's lowest locale tag and renders that silently" each match the code and the CI pins (template-locale-resolution.test.ts:155-168 lowest tag resolving; :194-201 named→en-US; :204-213 no-locale order ['en-US', undefined]; :215-222 named NOT widened). en/en-US "different ROWS of one bundle" — right (rows share name).
  2. EMAIL_TEMPLATE_FLOOR_LOCALE docblock — RIGHT. "the only rung ... retries after an exact miss on a call that NAMED a locale", "no fallback floor for any call that names one", "Loud refusal and silent fill are selected by the CALL" (= the !preferred gate), "this constant is its rung 2". Ships in 2 d.ts + src.
  3. EmailTemplateDefinitionSchema.locale TSDoc — RIGHT; ships in 17 tarball files.
  4. SendTemplateInput.template TSDoc ("no best match, no folding; resolved by the exact ladder on locale") — RIGHT; the loader does exact where only (template-loader.ts:111-117). Runtime-identical after stripping.
  5. warnEmailTemplateLocaleFloor emitted text — RIGHT and now DISCLOSED. Control flow unchanged (transpiled diff is the string alone). "no fallback floor for a send that names a locale ... A send naming NO locale does not fail: it drops to this bundle's lowest tag" matches the code; the guard only fires on bundles with declared rows, so "this bundle" is non-empty by construction. Ships in 4 dist js + src.
  6. warnEmailTemplateLocaleFloor docblock (ships via src/stack.zod.ts) — RIGHT; two early returns declared, ADR-0049 left open, both shapes pinned.
  7. Pending changeset .changeset/email-template-locale-floor.md (fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's note, compiles into the shipped CHANGELOG.md at release) — four sentence-level edits, frontmatter minor untouched, blob 13881152eeca24d44dae: (a) line 7-8 "for a call that NAMES a locale, retries exactly one rung — the literal en-US — and stops" [required]; (b) line 19-22 "the rung sendTemplate retries for a call that NAMES a locale. The full ladder — including the lowest-tag rung reachable only by a call that names NO locale — is on SendTemplateInput.locale" [required]; (c) line 17 "the one literal en-US rung a call that NAMES a locale gets" [declared beyond]; (d) line 13 "no floor for those calls" [UNDECLARED beyond]. All four RIGHT.
  8. New changeset .changeset/18056-email-template-locale-rungs.md — RIGHT: minor, carries Clause-②: yes, discloses the warning TEXT change, keeps "control flow is unchanged", no "byte-for-byte". Its "A test asserting on the old wording needs updating" is consumer guidance; no in-tree assertion at the merge base matched the old wording verbatim (toContain('en-US'), TEMPLATE_NOT_FOUND, "none tagged 'en-US'" only), so it is hypothetical and harmless.
  9. Test headers (not shipped): stack-email-template-locale-floor.test.ts:4,12-19 and email-template-floor-locale-parity.pin.test.ts:32-35 — RIGHT, both call shapes named. 17 tests pass.
  10. Generated content/docs/references/system/email-template.mdx — RIGHT: new spelling 1, different bundles 0, control BCP-47 locale 1; blob unchanged this round.

The five prior "Required changes for PASS" — ALL LANDED, with movers (line counts, prior head 708595dc→head, over packages/spec+.changeset): sole retry rung 1→0; single retry rung 1→0; Its logic is unchanged 1→0; control flow is unchanged 0→1; NAMES a locale 6→10 (plus one wrapped occurrence at .changeset/email-template-locale-floor.md:7-8 visible only to grep -Pz); lit control EMAIL_TEMPLATE_FLOOR_LOCALE 23→23; dark control 0→0. Item 5 (acceptance note): all three carriers verified verbatim at head — platform-objects/src/audit/sys-email-template.object.ts:10-11, service-messaging/src/objects/notification-template.object.ts:65, docs/qa/platform-checklist/areas/integration-system.json:818; email-template.form.ts really is silent on the floor (its one en-US hit is a helpText example, line 31); item-key-discriminators.ts:7-10 quotes a sentence with 0 hits in email-template.zod.ts (controls i18n bundle 1, must stay equal 1).

Edits beyond the required five — each same file, same defect class (an unscoped one-rung/floor claim), none touches logic: (a) "the single literal en-US rung" scoped, .changeset/email-template-locale-floor.md:17 — STAY; (b) title line stack-email-template-locale-floor.test.ts:4 scoped — STAY; (c) the acceptance note's shape (a marked correction naming three carriers and a successor, PR body only, no diff) — STAY; (d) "no floor for those calls" at .changeset/email-template-locale-floor.md:13 — STAY, but it was not declared (the round's "three beyond" undercounts by one).

"No logic moved" — HOLDS, two instruments: (i) line-shape filter over the remediation .ts diff: 24 changed lines, 0 non-comment; dark control stack.zod.ts 8fe5cb8e51..708595dc9c: 70 changed lines, 12 non-comment. (ii) transpile-stripped sha256: both test files SAME before/after (e5af793b…, 98e79f85…) while raw blobs differ; dark control DIFFERENT (cfd2950c… vs 01a4bacb…); identity control SAME. The three source files and the generated page are blob-identical between 708595dc and 642da7fd.

Dangerous direction — no new sentence claims what the resolver does not do. Boundary calls: named locale, no row, no en-US → 2 lookups, TEMPLATE_NOT_FOUND (locale=<named>), permanent (service-messaging/src/email-channel.ts:344-356 grades TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_UNSUPPORTED permanent); no locale, no en-USen-US by name, then load(undefined) → lowest tag, no log; one non-en-US row → named-other dead-letters, no-locale renders that row. Two precision caveats, not defects: when the named locale IS en-US there is no second lookup (wanted === DEFAULT skips the retry) — same outcome, one fewer query than "retries exactly one rung" implies; and "does not dead-letter" for no-locale calls presupposes a non-empty bundle whose lowest row is active (an empty bundle or inactive lowest row throws TEMPLATE_NOT_FOUND/TEMPLATE_INACTIVE, both permanent) — the one-place ladder text at contracts/email-service.ts:290-291 already states the empty-bundle case.

Completeness — no unscoped one-rung claim survives in packages/spec or .changeset at head. Wrapped-tolerant sweep (grep -rPzl '(?s)<w>[\s*/]+<w>…', spelling proven: escape form reads 1 on the file with the wrapped phrase and 0 on the file without; the literal-newline form reads 1 on the file without and is void): sole/single (retry) rung 0 files, different bundles 0, no fallback floor at all 1 (a quotation of the old text in the 18056 changeset), exactly one rung 4 files and one retry rung 9 files (all scoped, incl. the json-schema projections), only rung 1 and retries only 1 (scoped in the same paragraph). Lit control 6 files, dark 0.

② Semver level

minor on @objectstack/spec — correct and required. Rule: AGENTS.md on the PR head tree (blob fb8bae3e, identical on origin/main 2f11e2db), Post-Task Checklist step 3, lines 1067-1068: "The declaration is Clause-②: yes|no plus at most one arm …: yes takes at least minor". Line 1043 on that tree is "Copy a landed one — never import one" (the model-declaration self-test rule): the round is right and the prior record's 1043 was read off a different tree — the shared checkout's own branch carries AGENTS.md blob f04446be, where the same sentence sits at 1043. Re-derived on the packed tarball (pnpm pack of a fresh build of head, objectstack-spec-17.4.0.tgz, 2014 files): new describe in 23 files (dist 16 / json-schema 6 / src 1); new TSDoc scope 17; constant-docblock scope 3 (d.ts 2 + src); new warn text 4 dist js + src; nine old spellings 0 files each; lit control BCP-47 locale 64 files; dark 0. CHANGELOG.md ships (85532 lines; EMAIL_TEMPLATE_FLOOR_LOCALE 0 = #17884's note is still unreleased; control BREAKING 241); .changeset/ and *.test.ts do not ship; src/system/email-template.zod.ts and src/stack.zod.ts ship as source. Both changesets grade minor. Gates on head, run locally against the true merge base because CI never reached them (below): check-changeset-no-major exit 0 ("declares clause-② yes, no package graded patch"), check-adr-0087-registration exit 0, check-clause2-carriers --pair 18482 exit 0 (both carriers agree, declaration read from card comment 5698961929).

③ Boundary flags

  1. Check Changeset RED BY DESIGN — verified, NOT a reason for FAIL. Provenance: git log origin/main -- .changeset/email-template-locale-floor.md = one commit, a61ae59f93 (fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884, 2026-09-12), which is also its --diff-filter=A add; blob 13881152ee identical at the TRUE merge base 8fe5cb8e51 (the API base.sha 85c6d76e is the base-branch tip, not the merge base), at origin/main 2f11e2db, at a61ae59f93 and at the prior head; 0 commits on origin/main touch it after the merge base — nothing concurrent is clobbered. Gate reproduced locally: node scripts/check-empty-changeset.mjs --base 8fe5cb8e51 --head HEAD exit 1 with the M row, --self-test 159 assertions pass; the CI check-run's only substantive annotation is that refusal. DELIBERATE CORRECTION is the right arm: the PR's own note is a separately ADDED issue-scoped file, and the foreign note keeps its frontmatter and every other sentence — an edit, not an overwrite. Nuance: the note was already false when written (rung 3 predates it, email-template-render (b): sendTemplate with no locale renders an arbitrary locale instead of the en-US default #7731), this PR did not falsify it; the remedy is the same. Nothing but a human confirmation clears it legitimately: restoring republishes the false sentence; rename-into-a-second-changeset is the erratum shape AGENTS.md:686 forbids; skip-changeset skips the whole job via its if: but is for diffs publishing nothing and is open finding [finding] the skip-changeset label suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375; waiting for a release turns the edit into an ADD of a stale note. Population: 1 file. Check Changeset is NOT one of the seven required contexts (AGENTS.md:505-507 on the head tree), so the queue would not block on it; the block is the seat's own 「every check green」, to be waived by name. Consequence measured: the job stops at that step (no continue-on-error; later steps' if: lacks always()), so the no-major/Clause-② and ADR-0087 steps have NOT run in CI on this head — my local exit-0 runs are the only readings. All seven required contexts are success at 16:42Z.
  2. PR body Clause-② line is a near miss — ANSWERED, non-blocking. The body's line is backtick-wrapped; the repo's own reader (readClause2Line) returns near-miss/describing for it, declared yes for a bare Clause-②: yes, and declared yes for card comment 5698961929. Recommend the body line be made bare at column 0 before the label is stripped at PASS, so the body-only reading is a declaration; with minor on both changesets the no-major gate cannot refuse either way. Population: 1 line, PR body only.
  3. Undeclared fourth edit in the pending note (:13 "for those calls") — ANSWERED: keep; the round's declaration undercounts by one. Population 1.
  4. Residual floor reference, .changeset/email-template-locale-floor.md:5 title "report a bundle that has none" — ANSWERED: not a rung-count claim; the bullet at :23-24 states the exact report condition; leave. Population 1.
  5. Stale "best-matching locale" carriers OUTSIDE packages/spec — ESCALATED to the PM, one card if the seat does not already hold it: sys-email-template.object.ts:10-11, notification-template.object.ts:65, integration-system.json:818, plus the doubly-stale quotation at metadata-core/src/item-key-discriminators.ts:7-10. Repo-wide best-matching locale|picks the best = 10 files, of which these 4 are false claims (the rest are template-loader's own quotations, this PR's changeset, released CHANGELOGs and unrelated doc.zod text). Dedupe words: "best-matching locale", "sys-email-template.object.ts", "notification-template.object.ts", "integration-system.json:818", "item-key-discriminators". Not fixed here; correctly out of this card's surface.
  6. Released packages/spec/CHANGELOG.md — checked for a false ladder sentence: the 17.3.0 entry (~6741-6752) scopes "ends at en-US" to a NAMED locale and names the any-row rung, so it is consistent; population of released false ladder sentences 0. No docs-only PR needed.
  7. Precision caveats (①, dangerous direction) — noted for a successor; no edit required now.
  8. [finding] check:cross-package-test-inputs answers 1 or 0 depending on whether packages/spec has been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440 (check:cross-package-test-inputs reds with packages/spec/dist present) — not re-filed, not re-measured. ADR-0049 early-return question — not acted on; I concur with the prior record (A now; B only as a separately ruled card; C rejected).
  9. Instrument note: the Actions job-log endpoint is proxy-refused (CONNECT 403) — replaced by the check-run annotations endpoint; GET /search/issues not used.

Implemented-by: claude/issue-18056-email-template-locale-rungs
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

At-tier re-review → PASS on head 642da7fda5. Carriers stripped. ⛔ This PR does NOT land yet, and the reason is not the PR.

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T16:47Z.

reading value
record, adopted verbatim comment 5701154922
reviewer tier by transcript 121 / 121 claude-fable-5-1; control an os-dev round in the same container 297 / 297 claude-opus-5
independence pair Implemented-by: claude/issue-18056-email-template-locale-rungs · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6
--pair 18482 exit 0
required contexts all seven success at 16:42Z

⛔ What still blocks landing, and it is a decision, not a defect

Check Changeset is red by design on the gate's DELIBERATE CORRECTION arm, and the re-review verified that independently and more precisely than this seat had:

⚠️ And a consequence this seat had not measured: the job stops at that step — no continue-on-error, and the later steps' if: lacks always(). ⇒ the no-major / Clause-② and ADR-0087 steps have not run in CI on this head at all. The reviewer's local runs (check-changeset-no-major exit 0, check-adr-0087-registration exit 0, against the true merge base) are the only readings of those gates for this head. ⛔ That is a gap to state at landing, not to paper over.

⇒ The ask is narrower than this seat first put it

Check Changeset is NOT one of the seven required contexts (AGENTS.md:505-507 on the head tree), so the merge queue would not block on it. What blocks is this seat's own landing pre-check ③ 「every check green」.

⇒ the maintainer decision (already in the box, comment 5700685091) is two things, and only the first is a product question:

  1. May this PR scope PR fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's pending, unreleased release note? — this seat's recommendation: yes. Not doing it publishes, in the shipped CHANGELOG.md, a sentence that was already false and that this same release corrects elsewhere.
  2. A waiver of ③ by name, for Check Changeset only, on this head. ⛔ This seat does not waive its own gate; a waiver has to be given, not assumed.

What the re-review settled that the first one could not

All five owed changes landed, with movers re-derived (sole retry rung 1→0, single retry rung 1→0, Its logic is unchanged 1→0, control flow is unchanged 0→1, NAMES a locale 6→10, lit control 23→23, dark 0→0). 「No logic moved」 holds on two independent instruments, each with a live dark control. minor re-derived on the packed tarball (2014 files, new describe in 23, nine old spellings 0 each, lit control BCP-47 locale 64).

⭐ And one thing that had been a puzzle for three rounds today is now explained rather than patched: AGENTS.md's Clause-②⇒minor sentence sits at line 1043 on the shared checkout's branch (blob f04446be) and at 1067-1068 on origin/main (blob fb8bae3e). Re-measured by this seat directly. ⇒ the rounds citing 1043 read the sentence — on a different tree. ⛔ Not a copied number, and the remedy is 「cite origin/main with the sentence quoted」, ⛔ not 「read more carefully」.

Residue

⛔ Nothing new filed from this record: flag 5's stale best-matching locale carriers are already #18499 (filed by this seat at 16:2xZ, same three carriers plus the item-key-discriminators.ts quotation). Flags 1–4 and 6–9 are answered in place with their populations; #18440 is not re-filed by ruling of the record itself.

State

needs:contract-review stripped from both carriers (PR and card #18056), read back and matched. Card stays pm:dispatched with assignee os-warren — it runs to MERGED. ⛔ Ready is not flipped and auto-merge is not attached: a green, reviewed PR whose landing waits on a maintainer answer is not a PR to enqueue.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…t loop emits during post-apply verification (objectstack-ai#18528)

Fixes objectstack-ai#18451

Clause-②: yes (widening)

Declares the build-progress PHASE vocabulary on `@objectstack/spec/ai`,
executing ruling A on
`objectstack-ai/cloud#2172`. The `data-build-progress` frame has shipped
as prose only —
`AIToolContext.onProgress` documents the channel
(`contracts/ai-service.ts:639`) and its example
carries a `phase` (`:644`) — while nothing ever declared which phases
exist.

## Membership was measured, not designed

The ruling names a CLOSED enum, so each member is sourced. Provenance is
recorded in the module
itself, per member:

| member | where it came from |
|---|---|
| `structure`, `data`, `done` | the consumer's own declared union
`ChatBuildProgress['phase']` at
`packages/plugin-chatbot/src/ChatbotEnhanced.tsx:163` in objectui, and
the set its reader discriminates at
`packages/plugin-chatbot/src/mapMessages.ts:744` — `d.phase === 'data'
|| d.phase === 'done' ? d.phase : 'structure'`. Read in the objectui
checkout at `ff1d5ea8d171b65ed5576199807c382a3ccc5b49`. `structure`
doubles as that reader's coercion default. |
| `verify` | the post-apply verification window objectui#7388 asks the
panel to be able to name, and the reason cloud#2172 ruled the vocabulary
into the spec. Corroborated in THIS repo by the `verify_build` tool
`service-ai-studio` actually registers
(`packages/spec/src/system/constants/platform-tool-names.ts`). |

**What I could not source, and therefore did not add:** the producer's
own emission list. The
ruling lives on `objectstack-ai/cloud#2172` and the `cloud` repository
is not reachable from this
session — the ruling text here is taken from the director seat's filing
on the card, verbatim, and
the cloud issue is NOT reported as checked. No member was rounded out to
fill that gap.
`designing` was considered and REJECTED: it belongs to the sibling
`data-blueprint-progress`
frame, a different channel with its own reader, and a test asserts it
does not parse here.

## What this adds

- `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` /
`BuildProgressPhase` — the closed
vocabulary in lifecycle order. An out-of-vocabulary value is refused and
the refusal names the
  accepted set.
- `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR:
a required `phase` plus
an optional `hop` and `tool`. Deliberately `z.looseObject`, not strict:
the panel fields the
consumer already reads (`appLabel`, `items`, `done`, `total`, `seq`)
ride the same frame and are
objectui's to shape, so a strict schema here would refuse every frame
shipping today.
- `BUILD_PROGRESS_FRAME_TYPE` — the one literal both ends select on.

`hop` is typed `z.number().int().nonnegative()` rather than pinned to a
base on purpose: whether
the loop counts its first hop as 0 or 1 is part of the emitter's
placement, which cloud#2172 owns
and this card explicitly does not decide. `tool` is a free string, not a
closed set, because the
executable tool set is registered at boot and legitimately includes
plugin-contributed names
`PLATFORM_PROVIDED_TOOL_NAMES` cannot know about.

`api-surface` reads **6 added / 0 removed** on `./ai` — purely additive,
which is what the `minor`
changeset and the `Clause-②: yes (widening)` declaration record.

## Tests

Everything below was run on final head `3233eea673`, after `pnpm
--filter @objectstack/spec build`.

- **New pin** `packages/spec/src/ai/build-progress.test.ts` — 16 tests,
exit 0.
- **Affected package** `pnpm --filter @objectstack/spec test` — `Test
Files 484 passed (484)`,
  `Tests 13789 passed (13789)`, `VERDICT command-exit 0`.
- **Typecheck** `pnpm --filter @objectstack/spec typecheck` — exit 0.
Note the main `tsc --noEmit`
program does NOT include `**/*.test.ts` (verified with `--listFiles`: 0
hits for both test
files, 1 for the source); the test layer is covered by the
`tsconfig.test.json` leg, where a
`grep` for my files over the raw error output returns **zero** hits with
a lit control finding 5
  elsewhere. That is what proves the two new ADR-0122 assertions.
- **Derived gates** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`
derived **108** families; all 108 were run on this head and reconciled
with exit codes via
`--ran`: *"108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN"*. **107 exit
0.**
- **Repo-wide** `pnpm lint` (`eslint . --no-inline-config`) — exit 0,
run in full, not narrowed.

### The one non-zero, and why it is not this diff

`pnpm check:cross-package-test-inputs` exits 1, naming
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
`packages/spec/dist/`.
This diff touches neither that test nor `packages/cli`. Two legs on a
throwaway worktree at
pristine `origin/main` (`97233b90ca`), with **zero** of my source
changes:

- no `packages/spec/dist/` present: **exit 0**
- the same tree with only a built `packages/spec/dist/` copied in:
**exit 1**, byte-identical finding
- that `dist/` removed again: **exit 0**

So the trigger is the presence of a gitignored build output — which
AGENTS.md requires before the
dist-reading gates — not this change. Filed as an out-of-scope finding
in the report.

### Ablation — the pin is not vacuous

Run from the committed state, each leg proving the mutation reached disk
(occurrence counts of
both the removed and the injected text, plus a blob hash differing from
the HEAD blob) before any
verdict was read, and restoring by observed state (`git checkout HEAD --
path`, blob hash back to
`53034bc672c8de6dbe800cd923bba8b7f237c66d`, `git diff HEAD` empty)
rather than by exit code:

| mutation | result |
|---|---|
| `z.enum(BUILD_PROGRESS_PHASES)` becomes `z.string()` — the vocabulary
stops being closed | **3 failed / 13 passed**: all three refusal cases
red, every known-phase control still green |
| `z.looseObject` becomes `z.strictObject` — the floor becomes a ceiling
| **1 failed / 15 passed**: exactly the case asserting the consumer's
shipping panel fields survive |

## Acceptance notes

### File-surface accounting — all 17 files, in three buckets

The claim declared `packages/spec/src/ai/**`, the
`contracts/ai-service.ts` docblock, the generated
`api-surface` / `export-origins` / reference-page artifacts, and
`.changeset/`. **Nothing here is a
hand edit I chose** — bucket 3 is empty.

**Bucket 1 — generated (10 files).** Proven, not asserted: every one was
reverted to the merge base
`fb6b2c369e` and re-produced by re-running the generators on that tree
(`check:generated --fix` wrote `gen:schema`, `gen:api-surface`,
`gen:export-origins`, `gen:docs`,
`gen:strictness-ledger`; `gen:declaration-map` was run explicitly
because the aggregate does not
flag it when its companion is reverted in the same stroke). `git diff
HEAD` over all ten came back
**empty** — byte-identical reproduction — and the tree was then restored
with
`git restore --source=HEAD --staged --worktree`, index and tree both
clean.

| file | generator that wrote it |
|---|---|
| `packages/spec/api-surface/ai.json` | `gen:api-surface` |
| `packages/spec/export-origins/ai.json` | `gen:export-origins` |
| `packages/spec/declaration-map/ai.json` | `gen:declaration-map` |
| `packages/spec/authorable-surface/ai.json` | `gen:schema` (via
`check:authorable-surface`) |
| `packages/spec/json-schema.manifest/ai.json` | `gen:schema` |
| `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` |
`gen:strictness-ledger` |
| `content/docs/references/ai/build-progress.mdx` (new page) |
`gen:docs` |
| `content/docs/references/ai/index.mdx` | `gen:docs` |
| `content/docs/references/ai/meta.json` | `gen:docs` |
| `content/docs/references/index.mdx` | `gen:docs` |

**Bucket 2 — required by a gate, with the gate's own red quoted (3
files).**

- `packages/spec/src/type-alias-convention.pin.test.ts` — `pnpm
check:spec-parsed-alias` red:
*"`BuildProgressFrame` is the AUTHOR state of `BuildProgressFrameSchema`
and nothing names its
PARSED state. Declare `export type BuildProgressFrameParsed = …` next to
it … or, if `z.input`
and `z.infer` of `BuildProgressFrameSchema` are the same type, pin it in
`packages/spec/src/type-alias-convention.pin.test.ts` instead."* Both
schemas are isomorphic, and
that file's own header states why the pin beats a synonym: *"a permanent
synonym is a name an
author can only pick wrongly."* So this is the ADR-prescribed route, not
a preference — the file
is also the gate's machine-readable exemption registry, which is why the
registration must live
  there and nowhere else.
- `packages/spec/llms.txt` — `pnpm check:llms-txt` red:
*"`[count]` domain `ai` declares 11 schemas; `src/ai/` holds 12"* and
*"heading declares 200
schemas; `packages/spec/src/` holds 201"*. Hand-kept by design — the
gate says *"there is
deliberately no `gen:llms-txt`"* — and it ships to AI consumers inside
the npm tarball.
- `content/docs/getting-started/quick-reference.mdx` — `pnpm
check:quick-reference-counts` red:
*"section \"AI Protocol\" declares \"of 11 schemas\" but
`content/docs/references/ai/` publishes
12 page(s)"*. **Reduced to the gate-required minimum**: a one-line `11
of 11` to `11 of 12`. An
earlier revision of this branch also added a table row for the new page;
that was discretionary
polish, not needed for correctness, and it was dropped — the section now
carries the same
  partial shape the API section already has at `17 of 31`.

**Bucket 3 — my own judgement calls: none.**

If every path above reads as bucket 1 or 2, the claim's surface wording
was narrower than what one
new exported schema mechanically forces in this tree.
- **`contracts/ai-service.ts` was NOT edited.** The claim allowed its
docblock "if the frame
example should name the enum". The new module's own docblock points at
that file, and leaving
the contract file untouched keeps the diff off a path a sibling PR is in
(`email-service.ts`,
PR objectstack-ai#18482, is the only `contracts/` file in flight). Naming the enum
from the `onProgress`
  docblock is a good follow-up, not a requirement of this card.
- **No `XParsed` aliases were added.** Both schemas are isomorphic, and
ADR-0122's pin file states
the reason a permanent synonym is worse than none: it is a name an
author can only pick wrongly.
  The pins are the prescribed route and tsc proves them.
- **Not merged with `main`.** The branch is based on `fb6b2c369e`.
Independence was verified for
these paths, and the three hottest spec artifacts are sharded per domain
precisely so parallel
spec PRs stay textually disjoint. The merge queue rebuilds on the merged
generation. Declared
  rather than assumed.
- **Noted, not filed:** the sibling `extractBlueprintProgress` reader in
objectui collapses an
unknown `data-blueprint-progress` phase to `'designing'` by the same
pattern. It is out of scope
here and was already ruled on in objectui#7388 (its docblock makes "only
`done` is
authoritative" a contract, so the posture is deliberate). Carrier:
whoever picks up the
  `data-blueprint-progress` vocabulary, if that is ever ruled.

## Scope this card does not decide

Per the card: not the UI copy for each phase (objectui#7388's), and not
the emitter's placement in
the cloud agent loop (cloud#2172's).

Authored by the `domain:spec` execution seat, session
`session_01KB5PFtxuy1x3dcR5gxudx6`.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

2 participants