Skip to content

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

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-18056-email-template-locale-rungs
Sep 20, 2026
Merged

os-project-manager merged 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 test — 483 files, 13777 tests passed
  • pnpm --filter @objectstack/spec typecheck — clean
  • pnpm --filter @objectstack/spec check:generated — all 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-changeset — exit 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@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 bf61f0a196f8881a825e36793715ebb002c241a3 → packageMentionDocs.

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 <noreply@anthropic.com>

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 13881152ee→ca24d44dae: (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-US → en-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 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Ruling: batch #200 item 1 · letter A · maintainer 「同意」 2026-09-20T16:02Z

Director seat, summon #25, session_012GcsUbuqFGBibkEDMRC1eE. Presented with the recommendation A (confirm the pending-changeset correction; B restore-the-false-sentence and C wait-then-erratum refused); the maintainer agreed 「同意」. This comment is the human path check-empty-changeset names: it records the maintainer's confirmation on the PR, naming the note and what changed under it.

Ruling — A: the correction of the pending release note is confirmed as a release decision

Four-facet reading (this seat's own): ① one note, one truth; ② whoever configures locales from the release notes gets the measured rung count; ③ the gate did its job loudly and this is the confirmation it asked for; ④ no new surface.

Prior rulings read: #18652 (batch #156 item 1, the precedent for confirming a pending-note correction on a PR — its skip-changeset half does not transfer, that diff published nothing); AGENTS.md:686 (no errata to released notes); #18375 (skip-changeset definition).

Execution, same stroke

Ready for review; auto-merge (squash) armed; the queue lands it on the seven required contexts. #18056 closes on merge (Fixes). Labels untouched.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 20, 2026 16:04
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 2d892dd Sep 20, 2026
47 of 51 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-18056-email-template-locale-rungs branch September 20, 2026 16:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ConfigSchema.assignments (objectstack-ai#17852, objectstack-ai#18847) (objectstack-ai#19147)

Fixes objectstack-ai#17852
Fixes objectstack-ai#18847

## What

Implements maintainer ruling **A, narrow** (comment 5725370319, batch
objectstack-ai#154 item 1) verbatim.

`$ZodRecord`'s open-key branch (zod v4 core) runs `if (key ===
"__proto__") continue;` **above** `def.keyType._zod.run`, so no key
schema — regex, `.refine()`, `.superRefine()`, or one that rejects every
string — can ever see a `__proto__` key. `ObjectSchema.fields` used to
accept a document whose `fields` carried a `__proto__` own key and hand
back a document without it: success, silent, irreversible into whatever
`os build` writes.

Two mechanisms, one per name class, at the two sites the ruling names:

- **`packages/spec/src/data/object.zod.ts:1964`
(`ObjectSchema.fields`)** — wrapped in a new pre-parse guard
(`refuseRecordProtoKey`,
`packages/spec/src/shared/record-proto-key-guard.ts`) that reads the raw
input's own keys via `z.preprocess` and refuses a `__proto__` key with a
named, located issue (`fields.__proto__`) before the record ever parses.
`constructor` and `prototype` — which **do** reach the key schema
unskipped (today's regex admits them as ordinary lowercase words) — are
refused by the key grammar itself, via a `.refine()` beside the existing
snake_case regex.
- **`packages/spec/src/automation/builtin-node-config.zod.ts:923`
(`AssignmentConfigSchema.assignments`)** — the same pre-parse guard,
`__proto__` **only**. This slot's key type (`z.string().min(1)`) carries
no grammar; `constructor` and `prototype` are legal flow-variable names
today and are left legal — no ruling narrows this slot's accept set for
those two names.
- **`packages/spec/src/stack.zod.ts:3027-3029`** — corrected the false
`// Post-parse and advisory: the stack is valid and is returned
unchanged.` comment. It was false twice over: the parse could drop a
`__proto__` key, and `:3032` returns `mergeActionsIntoObjects(data)`,
not `data`. Region-disjoint from draft PR objectstack-ai#18482 (its hunks are old
lines 2853-2924), confirmed against the real PR file diff before
editing; nothing else in this file was touched.

## A side effect the wrapping caused, and its fix

`z.preprocess`'s `in` half is a `ZodTransform`, which unconditionally
hardcodes `_zod.optin = "optional"` — a preprocess accepts any input,
including `undefined`, regardless of what the wrapped schema does. Left
alone, that made `ObjectSchema.fields` (which carries no `.optional()`)
report as optional to `$ZodObject`'s own JSON-Schema requiredness check
(`objectProcessor`, `io === 'input'`), so the published `data/Object`
schema silently dropped `fields` from its `required` array while the
**runtime** parse still correctly refused a missing `fields`.
`refuseRecordProtoKey` now patches `optin`/`optout` on the pipe's inner
`def.in` (not the outer pipe, which every `.describe()`/`.optional()` a
caller chains afterward clones away) to mirror the wrapped schema's own
values — verified before/after with `z.toJSONSchema(ObjectSchema, { io:
'input' })`. See the docblock in `record-proto-key-guard.ts` for the
full mechanism.

## Two things flagged by the dispatching seat, answered directly

**`compose-stacks-merge-collection-refusal.test.ts`** — this is a
direct, mechanical consequence of the guard, not a defect found next
door, and it stays in this PR. The test's own independent `isCollection`
walker structurally pattern-matches `ObjectSchema.shape.fields`'s zod
type; before this change `fields` was a bare `ZodRecord`, and wrapping
it in `z.preprocess` necessarily makes it a `ZodPipe`. The walker's
`pipe` case only recursed into `def.in` (correct for a `.pipe()` combo,
where `in` is the original type) and missed the record hidden in
`def.out` (the convention `z.preprocess(fn, schema)` actually uses).
Fixed to check both sides of a pipe. The **production** merge/refuse
logic in `stack.zod.ts` (`declaresCollection`/`objectCollectionKeys`)
has the identical `def.in`-only blind spot, but it is functionally
unaffected here because `fields` is excluded from that logic **by
literal key name**, before `declaresCollection` is ever consulted —
confirmed with an end-to-end `composeStacks({ objectConflict: 'merge'
})` probe that still shallow-merges `fields` correctly. That production
blind spot is a real, separate, dormant defect for any *future*
collection-typed key that gets wrapped in `z.preprocess` (not `fields` —
that one is safe by name) and is reported below as an out-of-scope
finding rather than fixed here, since `stack.zod.ts` outside the
3027-3029 region is explicitly fenced off this card.

**Regenerated spec artifacts** — three, all produced by the repo's own
generators, none hand-edited:
-
`content/docs/references/{api/metadata,data/object,system/migration}.mdx`
— via `pnpm --filter @objectstack/spec gen:docs`, reflecting the new
`.describe()` text on `ObjectSchema.fields` (and, before the
`optin`/`optout` fix above, briefly and incorrectly downgraded `fields`
to "optional" — caught and fixed before this diff, confirmed by the
requiredness fix and a full rebuild).
- `packages/spec/dropped-refinements.baseline.json` — **hand-edited**,
not generated (it has no `gen:` script by design; `check:generated`'s
underlying `build-schemas.ts` prints the exact corrected `sites` arrays
on a mismatch, and this edit pastes those verbatim, extracted
programmatically from the build's own output rather than transcribed by
hand). Nine entries gained a `fields.out.keyType` /
`assignments.out.valueType`-shaped site: the new `.refine()` on
`ObjectSchema.fields`' key type, and the `.out` path segment the
`z.preprocess` wrapper's pipe structure introduces, neither of which
projects into the published JSON Schema (see "Known gap" below) —
`measured.droppedRefinementSites` moved from 553 to 562 accordingly.

## Known gap (stated by the ruling, not closed here)

The guard does not project into the published JSON Schema
(`packages/spec/json-schema/**`) — that general gap is objectstack-ai#18670 and this
card does not wait on it.

## Tests

- `packages/spec/src/shared/record-proto-key-guard.test.ts` (new) — pins
the guard in isolation against a minimal record: refuses `__proto__`
with a named, located issue; a **control** proves the underlying
unguarded record really would have silently dropped it; leaves ordinary
keys, non-object input, `.optional()` composition and a caller's own `{
error }` option untouched.
- `packages/spec/src/data/object.test.ts` — pins `ObjectSchema.fields`
refusing `__proto__` (named issue, never falls through to the
key-grammar's regex message), refusing `constructor`/`prototype` via the
key grammar (`invalid_key`, nested refine message), and still accepting
an ordinary document.
- `packages/spec/src/automation/builtin-node-config.test.ts` — pins
`AssignmentConfigSchema.assignments` refusing `__proto__`, and a
**preservation** pin that `constructor`/`prototype` remain accepted as
flow-variable names.
- `packages/spec/src/compose-stacks-merge-collection-refusal.test.ts` —
updated per the scope note above; all 62 cases pass.

Every pin is a behaviour pin against the pinned `zod@^4.4.3`, not a
version-string pin, per the dispatch's instruction.

## Gates run on this PR's head

- `pnpm --filter @objectstack/spec build` — clean.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**, including `check:api-surface` ✓ and
`check:authorable-surface` ✓ (both named by the ruling).
- `pnpm --filter @objectstack/spec test` — 498 files / 14569 tests, all
pass.
- `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit`,
`check:scripts-typecheck`, `check:test-typecheck`; the pre-existing
259-error/144-signature test-typecheck debt ledger is unchanged).
- `node scripts/check-adr-0087-registration.mjs --base origin/main` —
the changeset's `not-required (no-migration-prescription)` disposition
verified against the census (zero authored use anywhere reached).
- `node scripts/pm/dispatch-gates.mjs --commands` derivation for this
diff: **102 families derived, 99 run and green, 3 correctly
NOT-MEASURED** (`check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt` — each refuses on
`PREREQUISITE NOT MET`/exit 3, requiring a full ~80-package workspace
build outside this card's local scope; not a finding).
- Confirmed the fix reaches the rebuilt `dist/`, not only `src/`
(imported `dist/data/index.mjs` directly and re-probed).
- Rebased onto `origin/main` mid-flight (an unrelated `spec` PR landed);
rebuilt, re-ran `check:generated`, the full test suite and typecheck
again on the merged tree — all clean.

## Out-of-scope findings (not filed, not fixed here)

- **To file** (class a, reproducible): `stack.zod.ts`'s
`declaresCollection` (`case 'pipe': return declaresCollection(def.in,
...)`) only reads the `in` side of a pipe. For `z.preprocess(fn,
schema)` the real type sits in `out`, so a *future* collection-typed key
on `ObjectSchema.shape` wrapped in `z.preprocess` would silently stop
being refused by `objectConflict: 'merge'`'s collision guard (objectstack-ai#14848's
own shape). Harmless for `fields` today only because it is excluded by
literal key name first. Dedupe words: `declaresCollection`,
`objectCollectionKeys`, `z.preprocess`, `pipe def.in`, `objectConflict
merge`.
- **Noted, not filed**: the measurement lead in the dispatch (whether
`AssignmentConfigSchema`'s own `.catchall(z.unknown())` drops a
top-level `__proto__` variable the same way) was re-measured:
`$ZodObject`'s catchall branch (`handleCatchall`, zod v4 core) carries
the identical `if (key === "__proto__") continue;` skip, with its own
comment ("skip `__proto__` so it can't replace the result prototype via
the assignment setter"). So the lead **holds** — a variable literally
named `__proto__` at the top level of an assignment node config is
silently dropped by the catchall the same way. Per the dispatch's
instruction this is reported, not fixed, and not widened into this PR.
Carrier: whoever files it — dedupe words `AssignmentConfigSchema
catchall`, `handleCatchall __proto__`, `top-level assignment variable`.

Clause-②: yes (narrowing)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

3 participants