Repository navigation
docs(spec): scope the email-template locale-floor claims to a call that names a locale - #18482
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgments
Required changes for PASS (text-only, no logic):
② Semver level
③ Boundary flagsopen_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):
Implemented-by: 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>
|
| 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.sendTemplatematches(name, locale)exactly and retries exactly one rung — the literalen-US.New exported
EMAIL_TEMPLATE_FLOOR_LOCALEnames 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
- ⛔ Not restored from the merge base. That is the COLLISION remedy and the gate names it as the one thing not to do here — it would republish the false sentence.
- ⛔ Not renamed into a fresh
.changeset/<issue>-<slug>.md. Also the COLLISION remedy: it would leave fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's note standing and add a second entry — the erratum shape above. - ⛔
skip-changesetnot applied. There is an open finding ([finding] theskip-changesetlabel suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375) that this label suppresses exactly this DELIBERATE-CORRECTION refusal. Using it here would be doing on purpose what that card reports as a defect.
⇒ 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).
Generated by Claude Code
Contract reviewServed-tier: ① Derived judgmentsWhole-PR runtime delta, derived by comment-stripped
The five prior "Required changes for PASS" — ALL LANDED, with movers (line counts, prior head Edits beyond the required five — each same file, same defect class (an unscoped one-rung/floor claim), none touches logic: (a) "the single literal "No logic moved" — HOLDS, two instruments: (i) line-shape filter over the remediation Dangerous direction — no new sentence claims what the resolver does not do. Boundary calls: named locale, no row, no Completeness — no unscoped one-rung claim survives in ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
At-tier re-review → PASS on head
|
| 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:
- Provenance re-derived against the TRUE merge base
8fe5cb8e51—⚠️ ⛔ not the API'sbase.sha85c6d76e, which is the base-branch tip, a distinction this seat did not draw. The blob13881152eeis identical at the merge base, atorigin/main, ata61ae59f93(fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884's add) and at the prior head; 0 commits onorigin/maintouch it after the merge base ⇒ nothing concurrent is clobbered. - ⭐ A nuance that corrects the framing on both sides: the fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none #17884 note was already false when it was 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; it corrects a sentence that was never true. The remedy is unchanged, but 「your change made it false」 is not the accurate description and should not be the one the decision is taken on.
- Every route out was checked and refused for a stated reason: restoring republishes the false sentence; renaming into a second changeset is the erratum shape
AGENTS.md:686forbids;skip-changesetis for diffs publishing nothing and is open finding [finding] theskip-changesetlabel 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.
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:
- 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. - A waiver of ③ by name, for
Check Changesetonly, 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
…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>
|
Ruling: batch #200 item 1 · letter A · maintainer 「同意」 2026-09-20T16:02Z Director seat, summon #25, 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.
Execution, same strokeReady for review; auto-merge (squash) armed; the queue lands it on the seven required contexts. #18056 closes on merge ( Generated by Claude Code |
…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>
…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>
Fixes #18056
packages/specstated two different rung counts for one resolution. Thisdecides 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; thisbody restates it for legibility. The changeset is
minor, which that declarationrequires, and the diff genuinely ships (measured below).
Remediation round (text-only, no logic moved)
An isolated at-tier contract review of head
708595dc9creturned FAIL on thecompleteness of the surrounding claims, not on the work: the three-rung reading, the
untouched docs page and the Half B ablation all verified.
642da7fda5closes the fiverequired items, every one of them text:
.changeset/email-template-locale-floor.md— the pending, unreleased note fromfix(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, andits
--diff-filter=Aadd) still said "retries exactly one rung — the literalen-US" and "the resolver's sole retry rung", unscoped.changeset versionwouldhave compiled both verbatim into the published
packages/spec/CHANGELOG.md— whichthat package ships in its
files[]— beside this PR's correction of them: theerratum-in-a-later-entry form
AGENTS.md:686forbids. Both are now scoped to a callthat NAMES a locale, with a pointer to
SendTemplateInput.localefor the full ladder;the same file's "the single literal
en-USrung" is scoped for the same reason.packages/spec/src/stack-email-template-locale-floor.test.ts:12-14— header sentencescoped, with the no-locale case named beside it. Its title line's bare "no fallback
floor" is scoped the same way.
packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33— "itssingle retry rung" now names both rungs and which call shape reaches each.
Half B, where "byte-for-byte" is gone and "control flow unchanged" stays.
sys-email-template.object.tsis corrected — see Acceptancenotes.
⛔
Check Changesetis RED on this head by design, and must not be turned green. Thegate refuses because
.changeset/email-template-locale-floor.mdexists on the merge baseand 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-changesetsuppressing thisvery 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. Thegate 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
.tsfiles were reprinted throughthe TypeScript printer with
removeCommentsand hashed: stripped sha256c026b27d…and98e79f85…, byte-identical before and after, while both raw file hashes changed. Darkcontrol on a pair that really does move code (
8fe5cb8e51..708595dc9constack.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.resolveAndRenderTemplateandcreateSysEmailTemplateLoaderin@objectstack/plugin-email:en-US— also where a call naming no locale startsen-USrowA call that names a locale never reaches rung 3: it dead-letters with
TEMPLATE_NOT_FOUND(permanent). A call that names none never dead-letterson a non-empty bundle.
Verdict:
contracts/email-service.tswas right andsystem/email-template.zod.tswas 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-USrow fails withTEMPLATE_NOT_FOUND".examples/app-showcase/src/system/emails/index.ts— "exact match →en-US→(no-locale calls only) the bundle's lowest tag".
plugin-email/src/template-locale-resolution.test.ts— rung 3resolving 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— onerung 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/specevery floor claim is now scoped to "a call that NAMES a locale", theno-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.templateTSDoc said the service "picks the best-matching localerow". 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-USwere called "different bundles" one paragraph after the TSDocdefines a bundle as rows sharing one
name. They are different rows of onebundle; the rewritten sentence says so.
Both sides of the generated projection, all four numbers
That
describeprojects intocontent/docs/references/system/email-template.mdx.A source-only fix would have left the old sentence on the reference page:
packages/spec/src/system/email-template.zod.tscontent/docs/references/system/email-template.mdxControl:
BCP-47 localestill returns 1 in the generated file, so the zero above isa real absence and not an unreadable path. Before
gen:docsthe generated column read1 / 0— the trap, caught and closed.check:generatednow reports all 15 artifactsup to date.
It ships, measured on the built artifact
packages/specpublishesdistandsrc/**/*.zod.ts. In the built tree: 16files 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.
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.
supportedLocalesis REQUIRED insidei18n, so its absent arm is reachableonly via a stack with no
i18nblock; and with no supported set every bundle'sdeclaredlist is empty, so early return 2 skips exactly the same shapes. Whatreturn 1 actually buys is not reading
.mapoffundefined— deleting it takesdefineStackdown 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 ownconfig, real
allEmailsmodule) andpackages/qa/dogfood/.../email-template-materialization-fixture.ts(whole stack). Both carry an
en-USrow, so both hit the floor check before eitherearly return is reached.
en) has no fallback floor: the resolver retries only the literalen-US, so every unlisted recipient locale dead-letters permanently #17614 trap shape: 1 warning — the harness is not blindThe 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 thecommitted state, under a
trap ... EXIT INT TERMwith absolute paths.c50c54efae7ba260b6vs HEADa9f45392)4c24ea22vs HEADa9f45392)describeback (NAMES a locale2 → 1, blob0c068b72vs HEAD915c2e31)Restore verified each time:
RESTORED-OK … blob == HEAD, finalGIT_DIFF_HEAD_EMPTY=true. No ablation artefact is left in the tree.Verification
pnpm --filter @objectstack/spec test— 483 files, 13777 tests passedpnpm --filter @objectstack/spec typecheck— cleanpnpm --filter @objectstack/spec check:generated— all 15 artifacts up to dateeslint . --no-inline-configover the full repo population: 6796 files, 0errors, 0 warnings, exit 0 — run at final commit
708595dc9c, so no narrowingclaim is needed
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 3PREREQUISITE NOT MET, an unbuilt workspace closure, which CI builds fresh)origin/mainworktree:
check:cross-package-test-inputs. Leg A (all six of this PR's paths, nopackages/spec/dist/) → exit 0. Leg B (zero of this PR's paths, plus an emptypackages/spec/dist/) → exit 1, identical finding. It reds on the presence of alocal spec build, not on this diff. Already filed as [finding]
check:cross-package-test-inputsanswers 1 or 0 depending on whetherpackages/spechas 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/distpresent: exit 0, the same pre-build leg.This round (
642da7fda5)pnpm --filter @objectstack/spec exec vitest runover 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$?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-inputscheck:empty-changeset— exit 1, RED BY DESIGN (DELIBERATE CORRECTION arm, above).Its own
--self-testpasses, 159 assertions, so the instrument is sound.Acceptance notes
Noted, not filed — each with who would meet it:
packages/spec/src/system/email-template.form.tssays nothing about the floor, so theStudio authoring path teaches none of the above. A gap, not an error. Successor: the
next card touching the email-template authoring form.
packages/platform-objects/src/audit/sys-email-template.object.ts:10-11does 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 therecipient, falling back to
en-US" — verbatim the third of the three false declarationsthat
packages/plugins/plugin-email/src/template-loader.ts:20-22already names. The sameclaim 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.tsquotesemail-template.zod.tsas saying the service "picks the best match for therecipient's locale" — measured 0 hits there (controls
i18n bundle= 1,must stay equal= 1). The sentence lives, in a variant, incontracts/email-service.ts, and this PR corrects that variant, so the quotation isnow doubly stale. Successor: the next card on email-template identity keying.
describeequal to the runtime'sladder would close this class mechanically, in the shape
email-template-floor-locale-parity.pin.test.tsalready uses for the constant.Successor: none today — offered to the seat as a follow-up.
Generated by Claude Code
Generated by Claude Code