feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build - #20460
Conversation
Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…usal Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…w for the provenance refusal Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…igration entry, changeset, door pins Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…nversion at load Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…lidate-runs-definestack-refusals
…s for hasStackProvenance / STACK_PROVENANCE_MISSING Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
…spec link) Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
… validate/build Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 143 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 d0ba084b70ba858f96ef6426ba93cd577dede97a && git checkout d0ba084b70ba858f96ef6426ba93cd577dede97a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e956924e17b8407ff443b12ccdeefa64d3c5de06 5355f47597da44acf15b0e760b827fe766bef349 && git checkout -B drift-repro e956924e17b8407ff443b12ccdeefa64d3c5de06 && git merge --no-ff 5355f47597da44acf15b0e760b827fe766bef349
node scripts/docs-audit/affected-docs.mjs --json e956924e17b8407ff443b12ccdeefa64d3c5de06
|
Heads-up for this PR's dev:
|
…lidate-runs-definestack-refusals # Conflicts: # packages/cli/test/validate-build-gate-parity.test.ts
…defineStack Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20367 (body and all 11 comments — ruling Gate verdicts on the head: 34 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, judged against ruling B's execution parameters and the measured tree.
Nothing named wrong. ② Semver level
③ Boundary flagsDev report
Merge round Open questions:
Gates: ESCALATED (named questions, not verdict changes):
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Director pointer · 2026-09-29T01:24Z: the maintainer answered E1 of record |
… commits that decided them (stage 4) (objectstack-ai#20548) Part of objectstack-ai#20234 Clause-②: no ## What changed This is stage 4 of the staged sweep: the `data/` remainder. It covers the six `packages/spec/src/data/` files stage 3 (PR objectstack-ai#20533, landed `03b19d9cfd`) left out because an open PR held them, and nothing else. They are `object.zod.ts`, `filter-logic-conformance.ts`, `object.form.ts`, `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts`. Later stages cover the other areas, so this PR says `Part of`. The census below measured all six. Three of them carry comment or docblock sites that cite a tracker number answering 404. `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts` carry none, so they are not in the diff. Every such site has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123). That is **19 sites on 19 lines in 3 files, covering 9 numbers**. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and it says in its own words what that commit decided. Where a PR number was already on the line (`PR objectstack-ai#13529`), it stays beside the commit as the link. No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any of the 9 numbers: a search for each number, with and without `#`, finds nothing there. So every anchor is a commit: **9 distinct shas**. Stage 3 had already read these commits and recorded them in PR objectstack-ai#20533's body. They were not copied from there. Each one was re-read against the current line it anchors: its own message or diff names the number it replaces, and it made the change the line describes. `object.zod.ts` and `filter-logic-conformance.ts` moved on `main` after stage 3 read them (PRs objectstack-ai#20521 and objectstack-ai#20523). Each site was therefore re-read at this base, `03b19d9cfd`. Only comments changed. Every source file keeps its line count (20 lines out, 20 in, over 3 files), so no line citation into these files moves. One of the 20 lines held no dead citation: `filter-logic-conformance.ts:249`, the first half of a sentence reflowed onto `:250`. No code token moves (see the guard below). **No tracker number is added.** Every tracker number on an added line was already in the hunk it replaces. `PR objectstack-ai#13529` stands on three added lines, and on the three removed lines of the same hunks. It is the link beside commit `9dac1ae01`, which stage 3 recorded the same way. No reference page under `content/docs/references/` moved: none of the rewritten docblocks projects into one (`check:docs` at the head: `226 generated files in sync`). The PR adds one `patch` changeset for `@objectstack/spec` (see Changeset below). ## Census: the six files, before and after **Instrument.** This is the instrument of stages 1 to 3. It sends REST `GET /repos/objectstack-ai/objectstack/issues/N` without following redirects, for every distinct number cited in `packages/spec/src/data`. The population is: - the citation gate's own exported `CITATION_RE` and `NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`, `objectstack-ai/objectstack`, `framework`, `pre-` or `post-`; - widened case-insensitively to `Pre-`, `POST-` and `Framework`, as in stage 3; - N of 100 or more, excluding `summon` heads. Each site is classified by the TypeScript parser as a line comment, a docblock, a block comment or a string. Two cross-checks close the population. First, a raw `#N` count in each of the six files equals the census rows plus the cross-repo rows in five files. In the other two it is one higher, and the extra is a second number after a slash inside a string (`objectstack-ai#5322/objectstack-ai#5134` in a `note`, `objectstack-ai#6262/objectstack-ai#6433` in a test title). Both answer 200. Second, no spelled citation (`issue N`, `PR N`, `card N`) occurs in any of the six. **Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at the start, after every 100 numbers and at the end: 24 of 24 lit (200) and 24 of 24 dead (404) over 8 checkpoints in the base run, and 21 of 21 lit and 21 of 21 dead over 7 checkpoints in the head run. | reading | tree | numbers probed | 200 | 404 | 301 or other | dead sites, all of `data/` | dead sites, the six files | lines | files | numbers | |---|---|---|---|---|---|---|---|---|---|---| | before | base `03b19d9cfd`, probed 2026-09-29T01:11:59Z to 01:15:49Z | 601 | 572 | 29 | 0 | **77** | 19 | 19 | 3 | 9 | | after | head `53c9070dfd`, probed 2026-09-29T01:25:55Z to 01:29:35Z | 597 | 572 | 25 | 0 | **58** | 0 | 0 | 0 | 0 | The head probe found no number newly dead since the base probe: the same 572 numbers answer 200. The base reading of 77 equals stage 3's after reading at `96fd49caa2`. **Per file.** Cited sites here are every in-repo citation the population reads, live or dead. | file | cited sites (base) | dead sites before | by class | dead sites after | |---|---|---|---|---| | `object.zod.ts` | 120 | 15 | 8 docblock, 7 line comment | 0 | | `filter-logic-conformance.ts` | 97 | 3 | 2 docblock, 1 line comment | 0 | | `object.form.ts` | 31 | 1 | 1 line comment | 0 | | `data-engine.zod.ts` | 48 | 0 | | 0 | | `data-engine.test.ts` | 29 | 0 | | 0 | | `hook.form.ts` | 0 | 0 | | 0 | None of the 19 sites is a string, so this stage leaves no string token behind. ## Per-number table | number | sites / lines | anchor: what it decided | |---|---|---| | `objectstack-ai#8772` | 4 / 4, `object.zod.ts:2718`, `:2731`, `:2744`, `:2910` | `75b7c240a`: Direction 2 of the 2026-08-16 maintainer ruling. `ObjectSchema.create()` forces `required: true` on a `master_detail` reference under `controlled_by_parent` and refuses an explicit `required: false`. Raw parse stays tolerant, and runtime tolerance is the ruling's other half. Its changeset records the measurement that only the security gate closed that shape while the declaration surface accepted it (`:2731`). ADR-0055 stays cited beside it. It is the same anchor stage 3 gave `object.test.ts` | | `objectstack-ai#10165` | 2 / 2, `object.zod.ts:818`, `:1036` | `801296050`: `ttl.onlyWhen` with the canonical null predicate (maintainer ruling 2026-08-20, option A). One shared `onlyWhen` union, and both of `retention.onlyWhen`'s conflicts mirrored. Its diff wrote both `[objectstack-ai#10165]` blocks | | `objectstack-ai#10347` | 3 / 3, `object.zod.ts:1006`, `:1042`, `:1049` | `530c1df65`: the Archiver honours a declared `ttl`. It selects by the ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at` / `archive.after` otherwise (maintainer ruling 2026-08-20) | | `objectstack-ai#10527` | 1 / 1, `object.zod.ts:1005` | `5649efbf9`: refuses a diverging retention + ttl + archive triple at parse time. Its diff wrote this very paragraph | | `objectstack-ai#11195` | 1 / 1, `object.zod.ts:1791` | `b37231883`: `UserActionsConfigSchema` adopts `group` / `hideFields` / `rowColor` (the "last three" the line names) | | `objectstack-ai#11408` | 1 / 1, `object.zod.ts:2189` | `f11fc61c5`: declares `editMode` on the object document (maintainer ruling 2026-08-24, the `objectstack-ai#10144` declare-or-rule-out family, which stays cited) | | `objectstack-ai#13608` | 3 / 3, `object.zod.ts:2317`, `:2354`, `:2366` | `fc9ba76a5`: `publicSharing.eligibility` is held at redemption, not only at mint, fail-closed, with the undifferentiated `null` refusal. Its changeset heads with objectstack-ai#13608. It is the same anchor stage 1 gave `contracts/share-link-service.ts` | | `objectstack-ai#13195` | 3 / 3, `filter-logic-conformance.ts:190`, `:250`, `:525` | `9dac1ae01`, PR objectstack-ai#13529's squash commit, which stays as the link: `$exists` means has-a-value on driver-memory's live mingo path, its analytics face and driver-mongodb's `translateFilter` (the "last three key-presence exits") | | `objectstack-ai#12868` | 1 / 1, `object.form.ts:256` | `c459da6bc`: narrows the per-option `default` key out of the form-view options vocabulary, which offered a key nothing on that surface read. Commit `e808890958`, which wrote this line, names objectstack-ai#12868 as the same offer-vs-door class | The shas were checked at the base and again at `origin/main` `288611e3e5`. Every one matches exactly one commit (`git rev-parse --disambiguate`, count 1). Every one is an ancestor (`git merge-base --is-ancestor`, exit 0 for 9 of 9). The control leg `e9584681a4` also exits 0, and the repository is not shallow. For each commit, a grep of its own message or diff finds the number it replaces. Seven of the nine name it in the message. `fc9ba76a5` names it in its diff (20 lines, including its changeset heading), and so does `c459da6bc` (8 lines, including its changeset heading). Wordings to check, each true of its commit: - `object.zod.ts:2731` now reads 「closes that shape, and commit 75b7c24 records that the declaration and the enforcement disagree」. The measurement was the card's. The commit's changeset records it: "only the security gate closed that shape while the declaration surface accepted it". - `object.zod.ts:2189` reads 「Declared here by commit f11fc61's maintainer ruling」, and `:2744` reads 「the other half of commit 75b7c24's ruling」. This is stage 3's wording for the same relation (`object.test.ts`, 「the other half of commit 75b7c24's ruling」): the commit that landed the ruling and quotes it. - `object.zod.ts:1049` reads 「That is the whole of what [commit 530c1df] changed here」. Commit `52db1d1f2a` wrote the paragraph. `530c1df65` is the change it describes. ## Mechanical guard: no code token moves The check compares leaf tokens with comments stripped, base `03b19d9cfd` against head `53c9070dfd`. It uses the TypeScript parser's leaf tokens (TypeScript from the head's lockfile), so template literals are scanned in context, and it excludes JSDoc nodes. It ran over all 3 touched `.ts` files. It is the stage-3 instrument, unchanged. - Real run: 13,624 base tokens (object.zod.ts 8,774, object.form.ts 3,226, filter-logic-conformance.ts 1,624), **0 files with a token change** (exit 0). - Comment-insertion control (`object.form.ts`): 0 files changed, as expected (exit 0). - Positive control (a declaration inserted into `object.zod.ts`): 1 file reads DIFFER at token 1629 (exit 1). - Positive control (one digit changed inside the `objectstack-ai#5322/objectstack-ai#5134` `note` string in `filter-logic-conformance.ts`): 1 file reads DIFFER at token 889 (exit 1). Line balance: `object.zod.ts` +15 / -15, `filter-logic-conformance.ts` +4 / -4, `object.form.ts` +1 / -1. Line counts are equal at base and head: 3,240, 621 and 751. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/spec` is included. It says only that the provenance comments were re-anchored. `Clause-②: no`: no export, key, value or type moves (the guard above). Measured on the head's built package: `object.zod.ts` is `src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten comments also reach `dist`: - `9dac1ae01` appears in `dist/data/index.d.ts` (the `filter-logic-conformance.ts` docblock) and in 4 bundled `.js` files; - `fc9ba76a5`, `f11fc61c5` and `b37231883` each appear in 22 bundled `.js` files, and `c459da6bc` in 12; - the positive control, the pre-existing `object.zod.ts` sentence 「Fail-CLOSED at both points」, appears in 11 bundled `.js` files. ## Gates (head `53c9070dfd`) - **Citation judging pass, run as CI runs it:** `pnpm check:issue-citations && node scripts/check-issue-citations.mjs` exits 0. The self-test passes 73 cases in 7 batteries. The live run judged 6 citations across 3 files: 3 resolve (`objectstack-ai#9138` twice, `objectstack-ai#11410`) and 3 resolve as a pull request (`objectstack-ai#13529`, the link). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the head derived 79 families, and all 79 exit 0. `--ran` reports 79 run, 0 NOT MEASURED, 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` ran first, under the shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate met an unbuilt prerequisite. - `pnpm --filter @objectstack/spec run check:generated`: under the lock against that build, `All 15 generated artifacts are up to date`, VERDICT command-exit 0. - **Tests and typecheck:** - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data` under the lock: Test Files 107 passed (107), Tests 3527 passed, 1 todo (3528), VERDICT command-exit 0. It covers every test in `data/`, among them `object.test.ts`, which reads these schemas. - The 13 spec suites outside `src/data` that read the touched files' source text or pin their line numbers, under the lock: Test Files 13 passed (13), Tests 544 passed (544). They are stage 3's 12 (`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`, `src/api/api-entry-graph.pin.test.ts`, `src/contracts/scoped-context.test.ts`, `src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`, `src/system/constants/platform-object-names.test.ts`, `src/type-alias-convention.pin.test.ts`, `src/ui/dashboard.test.ts`) plus `src/shared/union-author-message-pins.test.ts`, which pins `data/object.zod.ts:855`. - `pnpm --filter @objectstack/spec typecheck` under the lock exits 0, including `check:test-typecheck` (53 files, 251 errors, 138 pinned signatures held). - **Lint, as a proven narrowing at the head:** `eslint --no-inline-config --format json` over the 3 touched `.ts` files gives 3 files, 0 errors and 0 warnings. All 3 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, which its own line 328 states), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base.** The branch forked from `03b19d9cfd`, stage 3's landing. `origin/main` then moved two commits (`05077d4c26`, PR objectstack-ai#20532, and `288611e3e5`, PR objectstack-ai#20536), and neither touches `data/`. `dispatch-gates` flagged its derivation as stale because `scripts/regen-artifacts.mjs` had moved, so `origin/main` was merged in (`53c9070dfd`, a clean merge with no driver-deferred path) before the gates ran. The PR's delta against `origin/main` is exactly its 4 files. `origin/main` has since moved two more commits: `7e36a3cd7c` (PR objectstack-ai#20531) and `ba5927f714` (PR objectstack-ai#20460). Neither touches `data/` or anything the gate derivation reads, and a re-derivation prints the same 79 commands. A no-driver `merge-tree` of the head onto `ba5927f714`, from a bare shared clone, exits 0. So there is no second merge. - **Open PRs, re-read at 2026-09-29T02:01Z:** 9 open PRs, and none touches any of the six files. The `data/` files open PRs touch are objectstack-ai#20458's `analytics*` files, objectstack-ai#20504's `driver/turso.*`, and objectstack-ai#20545's `filter-number-comparand-declared-type.*`, which is disjoint. Since the claim, PR objectstack-ai#20460 has landed (`ba5927f714`) without touching `filter-subtree-provenance.ts`. That file's 3 dead sites are outside this claim's fence, so they are left for a later stage. - **The rung.** Two anchored changes also have ADR-0087 entries in `packages/spec/src/migrations`: `cbp-master-detail-required-forced` for objectstack-ai#8772, and `form-view-option-default-retired` for objectstack-ai#12868. The second entry's own header names commit `c459da6bc`. This PR takes the commit rung, as stages 1 to 3 did. The D3 id is the more durable in-repo record, if the ruling's first rung is later read to include those entries. - **What stays in `data/` after this stage: 58 dead sites.** - **12 comment sites in files other open work still holds.** `analytics.zod.ts`, `analytics-strictness-batchd.test.ts` and `analytics-date-range-two-bound-window.test.ts` hold 5 (objectstack-ai#20300, PR objectstack-ai#20458). `driver/turso.zod.ts` and `driver/turso.test.ts` hold 4 (objectstack-ai#20437, PR objectstack-ai#20504). `filter-subtree-provenance.ts` holds 3. It was held by objectstack-ai#20367 and is now free (see above). - **3 comment sites stage 3 left on purpose.** They are the test-read `[objectstack-ai#6259]` marker at `api-derivation.ts:163`, the test comment at `api-derivation.test.ts:232` that names it, and `field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number. - **43 string sites**, left as tokens: 41 test strings (2 of them in the held analytics and turso test files) and the 2 exported `AGGREGATION_CASES` note strings in `aggregation-conformance.ts` (`:398`, `:407`, objectstack-ai#11065), which objectstack-ai#20489's claim holds. - **Outside `data/`,** the card's other remaining items are unchanged: the migrations and ui areas, the `liveness/**` notes, the `why` strings, the `PROVENANCE_WAIVERS` reason, and `rest-server.zod.ts`. - **The citation gate's reach.** It defers `packages/**/*.test.ts`. No test file is touched here, so all 3 touched files are in its judging population. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…low secret, at arm time and at registration (objectstack-ai#20529) (objectstack-ai#20551) Fixes objectstack-ai#20529 Clause-②: no (narrowing) ## What this changes ADR-0041 (status Accepted), `trigger-api` acceptance criteria: "Per-flow inbound endpoint (...) with a per-flow secret; HMAC signature verification (GitHub/Stripe style) and a constant-time compare." The trigger armed a flow's inbound hook with no secret, logging only a warning, and such a hook skipped signature verification. An `api` trigger with no secret is now refused at arm time and at registration. - **`@objectstack/trigger-api`** - `ApiTrigger.start()` throws when the binding's `config.secret` is absent, blank after trim, or not a string. The error names the flow and `config.secret`. It throws before anything is stored in the hook map and before any queue consumer is subscribed. - The arm-time warning is removed, because the state it described no longer exists. - `ArmedHook.secret` is now non-optional. `handleRequest` verifies every post, so the type system has no unsigned branch left to reach. - The route ledger's note, which recorded an unsigned posture, now records that every hook this door serves is signed. - **`@objectstack/service-automation`** - `registerFlow` gains `validateApiTriggerSecret`, placed after the three existing hard-fail validations. - It judges the binding that `deriveTriggerBinding` computes. That is the body of `resolveTriggerBinding`, split out so it also runs over a flow that is not registered yet. So the rule reads the very `config` object that `activateFlowTrigger` would hand `start()`, including the array-form precedence. - It applies whatever the flow's `status`, like the other registration refusals. - What callers see is unchanged in kind: - The `/automation` create, update and clone doors answer `400 VALIDATION_FAILED` with `details.fields[0] = { field: '(body)', code: 'invalid_value' }`. This throw has the same plain-`Error` shape (the flow-rejected message) that `packages/runtime/src/domains/automation-register-error-class.test.ts` case 4 already pins. - Boot skips the flow with the existing `[Automation] failed to register flow` warning. - No new error code, and no `packages/spec` edit. **Why the rule lives in two places.** `@objectstack/trigger-api` and `@objectstack/service-automation` have no dependency on each other. At boot the trigger registers on `kernel:ready`, after the flow pull, so the engine cannot ask it at registration time. The engine's copy is the publish-time refusal the author sees. The trigger's copy protects a host that binds without the engine. Both read the same binding `config`, so they cannot disagree about which flows need a secret. A single home that also reaches `os validate` would be a `packages/spec` rule (see below). That is the spec lane's call and is not made here. **Breaking.** The changeset `.changeset/20529-api-trigger-requires-secret.md` bumps both packages `minor`. Its BREAKING paragraph gives the remedy: set a non-blank `config.secret` on the start node. A flow that is only ever started explicitly is `type: 'autolaunched'`, with no `triggerType: 'api'`, and needs no secret. Its ADR-0087 disposition is `not-required (no-migration-prescription)`, accepted by `check-adr-0087-registration`. ## Pin sweep - **Pins of the old semantics, repo-wide.** A repo-wide `git grep` for the warning text, "unsigned post" and "accepts unsigned" (CHANGELOGs excluded) hit four places: - the test pin, flipped; - the trigger docblock, rewritten; - the route-ledger note, rewritten; - `skills/objectstack-automation/SKILL.md:356`. That file is a Tier H governed surface outside this card's file surface, so it is reported, not edited. - **The flipped pin carries weight.** "accepts unsigned posts when no secret is configured" became three cases, for a missing, a blank and a non-string secret. Each asserts all of the following: - `start()` throws, naming the flow and `config.secret`; - `listHooks()` is `[]`; - no queue subscription happened, and no `armed:` log line was written; - a post to that flow answers `404` with the full `RESOURCE_NOT_FOUND` body; - nothing was published or delivered, and the flow never ran. - **The guarded surface is kept verbatim.** The `401` missing-or-bad-signature assertions are unchanged; only the test title lost its "when the flow declares a secret" clause. Every other case that armed with `{}` now arms with a secret and signs its body, and its assertion is unchanged. - **Fixture triage.** Three existing fixtures registered an `api` flow with no secret: - `engine.test.ts`: the execution-history fixture changes type to `autolaunched`. It is only ever run through `engine.execute`, never an inbound hook. - `flow-trigger-kind-shared-resolver.test.ts` (the `type: 'api'` and `triggerType: 'api'` rows) and `flow-activation-ledger.test.ts` (the `api` entry path) now declare the secret. Their subject is kind resolution and ledger refusal, so they must stay `api`. - A repo-wide scan for `api`-kind flow definitions found no other fixture that reaches a real engine. The only other hits are in `packages/lint` and `packages/spec` tests, which never call `registerFlow`. - **New registration pins** (`api-trigger-secret-registration.test.ts`): - Five refusal cases: a `type: 'api'` flow with no, blank or non-string secret; a start-node `triggerType: 'api'` flow; and an `obsolete` flow. Each asserts that the flow is absent afterwards (`getFlow` is null and it is not in the runtime states) and that the `api` trigger was never started. - Two contrast cases: a signed flow registers, binds, and hands the trigger its secret; an `autolaunched` flow needs no secret and runs. - One re-registration case: a re-registration that drops the secret is refused, and the stored signed version stays, neither stopped nor re-started. ## Verification record (HEAD `b7325134`) - **Build.** I built the dependency closure of both packages, then ran a full `turbo run build --filter=!@objectstack/docs --concurrency=2`: 72/72 tasks, 0 cached. It was needed for the dist-reading gates. - **Tests** - `@objectstack/service-automation`: 150 files, 1845 tests, all passed. - `@objectstack/trigger-api`: 2 files, 26 tests, all passed. - Both suites ran on `b7325134`, after the last commit. - **Typecheck** - `@objectstack/trigger-api` passes. `tsc --listFiles` counts both of its test files. - `@objectstack/service-automation` passes, including `check:test-typecheck`. - **Ablation 1: arm-time refusal.** `scripts/ablation-replace.mjs` replaced the throw with the old `logger.warn`. - Landed: anchor 1 to 0, blob `7e60a8ab` to `cc123fba`. - Red: `Tests 3 failed | 8 passed (11)`. All three refusal cases failed with `AssertionError: expected [Function] to throw an error`. - Restored: blob equals HEAD `7e60a8ab`, and `git diff HEAD` is empty. - Green before and after: 26/26. - **Ablation 2: registration refusal.** The `validateApiTriggerSecret` call was deleted. - Landed: anchor 1 to 0, blob `679f73dd` to `e66c2db2`. - Red: `Tests 6 failed | 2 passed (8)`. All five refusal cases and the re-registration case failed with `expected [Function] to throw an error`. The two contrast cases stayed green. - Restored: blob equals HEAD `679f73dd`, and `git diff HEAD` is empty. - Both suites import the subject from relative source, so no `dist/` was involved. - **Gates.** `dispatch-gates --commands`, derived over this diff's 9 paths, gave 62 commands. All 62 exited 0. Reconciling with `--ran` gave "62 derived, 62 run, 0 NOT-MEASURED (a DERIVED zero)". `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3, not a measurement). After the full build it exited 0. `check:dts-closure` and `check:lean-entry-closure` were re-run after the full build too. - **Lint, narrowed and proven.** eslint `--no-inline-config --format json` over the 8 changed `.ts` files reported 8 files, 0 errors and 0 warnings. Three facts make that narrowing a measurement rather than a skip: - The population comes from the config: each file matches the `packages/**/*.{ts,tsx,mts,cts}` blocks, and none reported "File ignored". - The count comes from the JSON output. - The config never enables type-aware linting (no `parserOptions.project`; `eslint.config.mjs` states this at lines 326–328), so this diff cannot move any untouched file's verdict. - **Declared to CI:** the repo-wide `pnpm lint`, the downstream consumer suites of `@objectstack/service-automation`, and the full farm. ## The three measurements 1. **Run identity: yes, for the documented pattern.** The inbound trigger supplies no user. A fired run takes the identity of the flow's declared `runAs`, which defaults to `'user'`. - Under the default, data nodes are refused for want of a principal. - A flow that declares `runAs: 'system'` runs its data nodes with system elevation. The shipped worked example declares `runAs: 'system'`, because it creates a record. 2. **Can a non-admin read `config.secret`: yes, by source reading.** I reported it to the seat as an out-of-scope security finding. It is not changed here. 3. **Shipped examples, templates, scaffolds: no.** The only shipped `api` flow is the showcase's worked example, and it carries a secret, so `examples/**` needs no edit. `packages/create-objectstack` declares no `api` flow. One thing did turn up: the published automation skill describes the secret as optional (reported below). ## `os validate` reach **No.** `os validate` never builds an `AutomationEngine` or calls `registerFlow`. `packages/cli/src/commands/validate.ts` runs the `defineStack` parse, the `@objectstack/lint` authoring rules and the capability preflight. I measured it on a throwaway stack (deleted afterwards) that declares one `type: 'api'` flow with no secret and `requires: ['automation', 'triggers', 'queue']`: `os validate` printed `✓ Validation passed`, exit 0. objectstack-ai#20367 (PR objectstack-ai#20460) runs the stack's `defineStack` refusals, and this refusal is not one of them. For `os validate` to see it, the rule would need to be a `defineStack` refusal next to the trigger-capability refusal (keyed on `resolveFlowTriggerKind`), or a `validate-flow-trigger-readiness` rule in `packages/lint`. Both belong to another lane. ## Acceptance notes - **`hookId` fallback left as is.** A secret is now mandatory for every armed hook, so the fallback token no longer has an unsigned form and nothing concrete argues for changing it here. - **Out-of-scope findings, reported to the seat and not filed from here:** - `skills/objectstack-automation/SKILL.md` (lines 52 and 356) calls the secret "strongly recommended" and describes `type: 'api'` as "invoked explicitly … **or** bound as an inbound webhook". The engine binds every `type: 'api'` flow to the inbound trigger, so an author following it now writes a flow the runtime refuses. The file is Tier H. - `os validate` passes a flow the engine refuses (measured above). - The measurement ② finding. - **`content/docs/**`.** No line calls the inbound secret optional (0 hits), so there is no docs edit. Two observations, not filed: - `content/docs/automation/flows.mdx` says an `api` flow "inherits its organization from whoever triggered it". The inbound trigger passes no caller session. - `content/docs/automation/webhooks.mdx` §16 still lists inbound webhooks as a non-goal with "no runtime". - Carrier for both: none. - **Not done here:** - No `scripts/adr-anchors/` entry for ADR-0041 was added. That path is outside this card's file surface. - Whether the Studio flow designer (objectui) can author an `api` flow's `config.secret` is not measured. The sibling repo is not checked out in this container. - **Gate list size.** Derived over the paths this diff actually touches, the list is 62 commands. The dispatch-time list over the expected paths was 92, because it also included `examples/**` and `content/docs/**` paths this diff never touched. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… sites to the commits that decided them (stage 5) (objectstack-ai#20576) Part of objectstack-ai#20234 Clause-②: no ## What changed This is stage 5 of the staged sweep: the `ui/` area (`packages/spec/src/ui/**`, 133 files), plus two sites freed since stage 4: `data/filter-subtree-provenance.ts` (PR objectstack-ai#20460 landed without touching it) and `meta-spelling/manifest-collection-spelling.ts` (the census hand-over, comment 5882628946 on objectstack-ai#20234). `ui/view-grouping-query.ts` is excluded because objectstack-ai#20446's claim holds it; it carries 4 citations and none of them is dead, so the exclusion removes nothing. Later stages cover the other areas, so this PR says `Part of`. Every comment and docblock site in that population that cites a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123). That is **87 comment sites**: 84 re-anchored and 3 respelled. - **84 re-anchored, over 25 numbers.** Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what that commit decided. One line (`ui/dashboard.zod.ts:686`) quotes ADR-0087 itself; it keeps ADR-0087 as its citation and paraphrases the amendment heading instead of quoting its number. - **3 respelled**, so each number of a sibling pair carries its own qualifier: `ui/view.zod.ts:3634` now reads `objectui#6110 + objectui#6111`, and `ui/component.zod.ts:3479` and `:4140` now read `objectui#8221's PR objectui#8758`. Each second number answers 404 here, and the sentence attributes it to objectui (objectui REST: `issues/6111` 200, `pulls/8758` 200, merged 2026-09-09). Only comments changed, plus the one generated reference page they project into and a patch changeset. Every source file keeps its line count (90 lines out, 90 in, over 25 files). Three of the 90 lines held no dead number; each is the other half of a rewritten sentence: `ui/action.zod.ts:400`, `ui/action-param-carryover.test.ts:13` and `ui/expression-bindable-text-keys.test.ts:119`. No code token moves (see the guard below). **No tracker number is added.** Every tracker number on an added line was already on the lines it replaces, and no `PR #N` is added. ## Census: before and after **Instrument.** This is the instrument of stages 1 to 4, rebuilt for this stage. It sends REST `GET /repos/objectstack-ai/objectstack/issues/N` without following redirects, for every distinct number cited in the population. The population is: - the citation gate's own exported `CITATION_RE` and `NON_CITATION_HEADS` at the base, kept when the qualifier is none, `objectstack`, `objectstack-ai/objectstack`, `framework`, `pre-` or `post-`; - matched case-insensitively (`Pre-`, `POST-`, `Framework`); - N of 100 or more, excluding `summon` heads. A qualifier covers only the number it is joined to. Each site is classified by the TypeScript parser as a line comment, a docblock, a block comment or a string. **Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at the start, after every 100 numbers and at the end: 18 of 18 lit (200) and 18 of 18 dead (404) over 6 checkpoints in the base run, and 15 of 15 and 15 of 15 over 5 checkpoints in the head run. | reading | tree | numbers probed | 200 | 404 | 301 or other | dead sites | lines | files | of which comments | of which strings | |---|---|---|---|---|---|---|---|---|---|---| | before | base `487a7846df`, probed 2026-09-29T02:57:01Z to 02:59:36Z | 400 | 372 | 28 | 0 | **111** | 110 | 26 | 90 | 21 | | after | head `83e39641d0`, probed 2026-09-29T03:15:00Z to 03:18:06Z | 383 | 372 | 11 | 0 | **24** | 23 | 8 | 3 | 21 | The head probe found no number newly dead since the base probe: the same 372 numbers answer 200. The head was probed at `83e39641d0`; every census file is byte-identical at the final head. **Cross-check under the grammar that landed during this stage.** PR objectstack-ai#20554 (`199002b3e4`) landed the closed qualifier set while this stage ran, and it reads `objectui PR objectstack-ai#8758` as objectui's number. Re-run with that gate's own `extractCitations` and `namesThisRepository`, the same population reads **108** dead sites before and **21** after, all 21 test strings. The difference is exactly the three `objectui PR objectstack-ai#8758` prose sites below, which that PR's own header measured as "census deaths here that are not deaths at all". **Per file.** Cited sites are every in-repo citation the population reads, live or dead. | file | cited sites (base) | dead before | by class | dead after | |---|---|---|---|---| | `data/filter-subtree-provenance.ts` | 9 | 3 | 3 docblock | 0 | | `meta-spelling/manifest-collection-spelling.ts` | 8 | 2 | 2 line comment | 0 | | `ui/action-param-carryover.test.ts` | 5 | 3 | 2 line comment, 1 string | 1 | | `ui/action.test.ts` | 39 | 3 | 3 line comment | 0 | | `ui/action.zod.ts` | 90 | 7 | 5 docblock, 2 line comment | 0 | | `ui/bulk-action.test.ts` | 9 | 4 | 2 line comment, 2 string | 2 | | `ui/bulk-action.zod.ts` | 9 | 3 | 2 docblock, 1 line comment | 0 | | `ui/component-element-navigation-17987.test.ts` | 8 | 5 | 1 docblock, 4 string | 4 | | `ui/component-type-vocabulary.test.ts` | 8 | 2 | 2 docblock | 0 | | `ui/component-type-vocabulary.ts` | 2 | 1 | 1 docblock | 0 | | `ui/component.test.ts` | 190 | 22 | 11 line comment, 11 string | 11 | | `ui/component.zod.ts` | 265 | 20 | 16 docblock, 4 line comment | 0 | | `ui/dashboard.zod.ts` | 52 | 1 | 1 docblock | 0 | | `ui/expression-bindable-text-keys.test.ts` | 3 | 2 | 2 line comment | 0 | | `ui/expression-bindable-text-keys.zod.ts` | 4 | 2 | 2 docblock | 0 | | `ui/form-select-option.test.ts` | 3 | 1 | 1 docblock | 0 | | `ui/index.ts` | 15 | 2 | 2 line comment | 0 | | `ui/interaction-config-retirement.test.ts` | 19 | 1 | 1 docblock | 0 | | `ui/react-blocks.test.ts` | 9 | 2 | 1 docblock, 1 string | 1 | | `ui/react-blocks.ts` | 17 | 4 | 2 docblock, 2 line comment | 0 | | `ui/view-form-features-root.test.ts` | 4 | 1 | 1 line comment | 0 | | `ui/view-metadata-schema.test.ts` | 31 | 3 | 2 line comment, 1 string | 1 | | `ui/view-submit-redirect-url.test.ts` | 8 | 1 | 1 line comment | 0 | | `ui/view.test.ts` | 136 | 2 | 1 docblock, 1 string | 2 | | `ui/view.zod.ts` | 331 | 13 | 10 docblock, 3 line comment | 2 | | `ui/widget-i18n-retirement.test.ts` | 17 | 1 | 1 line comment | 0 | `ui/` alone went from 106 dead sites in 24 files to 24. The other 107 `ui/` files carry no dead site. ## Per-number table Anchors are 9-hex commit abbreviations. "Wrote" means the commit's own diff added the line being rewritten. | number | comment sites / files | anchor: what it decided | |---|---|---| | `objectstack-ai#5970` | 4 / 2, `action.zod.ts:819`, `action.test.ts:268`, `:304`, `:354` | `97e7e3caa`: `ActionSchema.visible` / `disabled` speak one condition shape; `visible` gains its boolean arm. Stage 1's anchor for the same number | | `objectstack-ai#6276` | 7 / 1, `component.zod.ts:34`, `:165`, `:568`, `:2455`, `:2546`, `:2554`, `component.test.ts:2126` | `78f0be872`: declares `element:record_picker`'s flat `sort` / `limit` on the objectstack-ai#5611 rule (maintainer ruling 2026-08-08, direction A). It wrote `:34`, `:2455` and the "enumerate by the renderer's read pattern" lesson | | `objectstack-ai#8794`, `objectstack-ai#8836` | 3 / 1, `filter-subtree-provenance.ts:130`, `:131`, `:156` | `1850ebbb0`: corrects the reuse-safety claim from the survey and pins the invariant. It wrote `:131` itself. Stages 1 and 3 gave both numbers this anchor | | `objectstack-ai#9933` | 7 / 2, `view.zod.ts:2517`, `:5060`, `:5086`, `:5118`, `:5513`, `view-metadata-schema.test.ts:398`, `:410` | `d5552ca13`: admits `columnState` as an explicitly runtime-only view-overlay key, rejected by name at every authoring door. It wrote "explicitly out of objectstack-ai#9933's scope" | | `objectstack-ai#9972` | 3 / 2 (+1 unread), `component.zod.ts:2213`, `component.test.ts:382`, `:3565`; also `:3612`'s `objectstack-ai#9881/objectstack-ai#9972`, a slash-joined spelling the grammar does not read | `60e0f900a`: records the live read point of `page:tabs` `items[].icon` and its accept-pin. It wrote the `:382` header | | `objectstack-ai#10194` | 1 / 1, `manifest-collection-spelling.ts:71` (`pre-objectstack-ai#10194`) | `2306a765c`: `/meta/theme` and `/meta/analytics_cube` stop storing any JSON as success and validate at the write door. The line now says "the store-anything branch from before commit 2306a76". Stage 1's anchor | | `objectstack-ai#10274` | 6 / 2, `component.zod.ts:2304`, `component.test.ts:308`, `:405`, `:3591`, `:3603`, `:3612` | `d1ba685ec`: re-measures the four pin citations and gates the class. Its gate header records that the re-measure found two anchors wrong since they were written, which is why a refresh re-reads. Stage 3's anchor | | `objectstack-ai#10485` | 4 / 4, `index.ts:51`, `interaction-config-retirement.test.ts:116`, `widget-i18n-retirement.test.ts:112`, `manifest-collection-spelling.ts:67` | `35ad101bc`: retires the `themes` carrier and `ThemeSchema` whole (ruled B, 2026-08-21; ADR-0049 stays cited). Stage 1's anchor | | `objectstack-ai#11284` | 5 / 2, `react-blocks.ts:39`, `:94`, `:109`, `:295`, `react-blocks.test.ts:139` | `5383fa670`: the react tier converges on the metadata-tier vocabulary, deprecate-first. Its changeset heads "(objectstack-ai#11284, maintainer ruling 2026-08-23)" | | `objectstack-ai#11350` | 1 / 1, `index.ts:105` | `ece4dad31`: records the maintainer ruling of 2026-08-23 that a type in an entry's public declarations must be nameable from that entry. Same wording as stage 1's `kernel/index.ts:53` | | `objectstack-ai#11507` | 2 / 2, `component.zod.ts:1465`, `component.test.ts:2726` | `88b9d749a`: declares `sys_activity.type` an open, author-extensible vocabulary (maintainer ruling 2026-08-24, direction 4). Stage 3's anchor | | `objectstack-ai#11658` | 2 / 2, `component.zod.ts:1464`, `component.test.ts:2725` | `1a6a19c31`: opens `RecordActivityProps.types` to author-contributed kinds, executing that ruling. Stage 3's anchor | | `objectstack-ai#11703` | 3 / 2, `action.zod.ts:399` to `:400`, `:460`, `action-param-carryover.test.ts:12` to `:13` | `5cb62d88b`: `clone_permission_set` carries all five copied facets; its params list had silently dropped three. The lines now name "the silent-drop shape commit 5cb62d8 fixed" | | `objectstack-ai#11753` | 5 / 2, `action.zod.ts:66`, `:390`, `:398`, `:409`, `action-param-carryover.test.ts:1` | `0e4e51b0a`: `ActionParamSchema.carryOver`, the spec half of the 2026-08-25 maintainer ruling (recommendation A). It wrote every one of these lines, and its changeset records the `visible: false` measurement `:398` names | | `objectstack-ai#12194` | 1 / 1, `view.zod.ts:4838` | `311433f6b`: declares the metadata item-name grammar (`QUALIFIED_ITEM_NAME_PATTERN` among it) and refuses it at the publish door. Stage 2's anchor | | `objectstack-ai#12868` | 5 / 2, `view.zod.ts:2938`, `:2966`, `:3179`, `:7087`, `form-select-option.test.ts:4` | `c459da6bc`: narrows the per-option `default` key out of the form-view options vocabulary. Its changeset records the ruled census `:2966` cites ("measured ZERO occurrences"). Stages 3 and 4's anchor | | `objectstack-ai#12950` | 3 / 2, `component-type-vocabulary.ts:4`, `component-type-vocabulary.test.ts:4`, `:101` | `225e7690f`: created `component-type-vocabulary.ts`; its message records the readiness read `:101` pins (`global:search` and `global:notifications` stay declared) | | `objectstack-ai#13156` | 2 / 2, `view-form-features-root.test.ts:70`, `view-submit-redirect-url.test.ts:110` | `fd289be45`: strips tracker ids from function-declaration-built refusal prose. It wrote both lines. Stage 3's wording ("commit fd289be's strip") | | `objectstack-ai#13670` | 1 / 1, `expression-bindable-text-keys.zod.ts:72` | `8c6a7fc0b`: records `text.value` as deliberately omitted; its message states the ruling that `text`'s evaluation channel is `content` alone | | `objectstack-ai#13672` | 3 / 2, `expression-bindable-text-keys.zod.ts:89`, `.test.ts:65`, `:118` | `e854a531a`: narrows the `button` row to the spelling its key reaches, and records `action:button` and `ui:button` as deliberately out | | `objectstack-ai#16626` | 1 / 1, `component.test.ts:2336` | `30b099078`: the objectui pin bump to `53ded82bf7a4` that ships objectui#7754's array-analytics lowering, the door the family waited on. The association is PR objectstack-ai#16788's body (it names objectstack-ai#16626 as the card it lands), and `30b099078` is that PR's merge commit; neither its message nor its diff names objectstack-ai#16626 (the stage-3 objectstack-ai#11065 precedent) | | `objectstack-ai#17987` | 9 / 2, `component.zod.ts:10`, `:4014`, `:4062`, `:4153`, `:4193`, `:5068`, `:5226`, `:5457`, `component-element-navigation-17987.test.ts:4` | `e233db9db`: declares element-level `navigation` on `object-kanban` / `object-calendar` and gives `object-timeline` its `ComponentPropsMap` row, executing the objectui#8652 ruling (verbatim `B`) | | `objectstack-ai#18003` | 1 / 1, `dashboard.zod.ts:686` | **ADR-0087**, the rung above a commit. The line quoted the ADR's own amendment heading, number included. It now reads "(ADR-0087, its 2026-09-13 amendment, 「the level half」)": the ADR stays the citation and the fragment it quotes is verbatim | | `objectstack-ai#18177` | 5 / 2, `bulk-action.zod.ts:51`, `:169`, `:262`, `bulk-action.test.ts:61`, `:307` | `adabccf5f`: `BulkActionParamSchema` is strict and declares `dependsOn`, executing decision batch objectstack-ai#146 item 4, letter A | | `objectstack-ai#6111` | 1 / 1, `view.zod.ts:3634` | respelled `objectui#6111` (not re-anchored): it is objectui's number | | `objectstack-ai#8758` | 2 / 1, `component.zod.ts:3479`, `:4140` | respelled `PR objectui#8758` (not re-anchored): objectui's PR objectstack-ai#8758, merged 2026-09-09 | No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any of the 25 re-anchored numbers except objectstack-ai#18003. ADR-0126 mentions objectstack-ai#11703 and objectstack-ai#11753 only as references ("permission-set precedent"), not as the record of either decision. **Anchor checks.** Every sha on an added line is one of 23, and none is on a removed line. At the base `487a7846df`: - each matches exactly one object (`git rev-parse --disambiguate`, count 1, 23 of 23); - each is an ancestor (`git merge-base --is-ancestor`, exit 0, 23 of 23); the control leg `e9584681a4` also exits 0, and the repository is not shallow; - for 22 of the 23, a grep of the commit's own message or diff finds the number it replaces (the message for 16; the diff for `0e4e51b0a`, `5383fa670`, `c459da6bc`, `225e7690f`, `e854a531a`, and for objectstack-ai#8836 in `1850ebbb0`). `30b099078` is the exception explained in the table. - Each commit was read for the rule its line states, not only for the number. In most cases the commit wrote the very line it now anchors. Wordings to check, each true of its commit: - `filter-subtree-provenance.ts:130` and `:156` read 「survey commit 1850ebb records」: the survey was the card's, and the commit's message records its measurement. It is stage 3's wording for the same relation (「from the survey it records」). - `component.zod.ts:1465` and `component.test.ts:2726` read 「maintainer ruling commit 88b9d74 declared」: that commit landed the ruling (direction 4) as the `sys_activity.type` declaration. - `manifest-collection-spelling.ts:71` reads 「the store-anything branch from before commit 2306a76」: before that commit, `PUT /meta/theme/:name` stored any JSON as success. ## Mechanical guard: no code token moves The check compares leaf tokens with comments stripped, base `487a7846df` against the head. It uses the TypeScript parser's leaf tokens from the head's lockfile, so template literals are scanned in context, and it excludes JSDoc nodes. It ran over all 25 touched `.ts` files, and every control mutates the head text in memory only. - Real run: 101,836 base tokens, **0 files with a token change** (exit 0). - Comment-insertion control (`ui/index.ts`): 0 files changed (exit 0). - Positive control (a declaration inserted into `ui/view.zod.ts`): 1 file reads DIFFER at token 19222 (exit 1). - Positive control (one digit changed in a `component.test.ts` test title): 1 file reads DIFFER at token 14972 (exit 1). Line balance holds in every file, 90 out and 90 in over the 25, and every line count is equal at base and head. Tracker numbers: added-not-removed is empty in every file. The net-removed numbers are the 25 in the table, 85 sites: the census's 84 comment sites, plus the slash-joined `objectstack-ai#9972` at `component.test.ts:3612`. ## Generated page `check:generated` proved one artifact stale: `content/docs/references/ui/expression-bindable-text-keys.mdx`, the projection of `expression-bindable-text-keys.zod.ts`'s module docblock. `check:generated --fix` regenerated only that page, and a re-run read `All 15 generated artifacts are up to date`. Its two changed lines are the `:72` and `:89` substitutions verbatim. No other docblock here projects into a reference page, and nothing under `skills/**` moved. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/spec` is included. It says only that the provenance comments were re-anchored. `Clause-②: no`: no export, key, value or type moves (the guard above). Measured on the head's built package: 6 touched sources are `src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten comments also reach `dist`: - `c459da6bc` appears in 32 bundled `.js` files and 2 `.d.ts`; - `adabccf5f` in 24 `.js` and 2 `.d.ts`; `d5552ca13` and `0e4e51b0a` in 24 `.js` each; `e233db9db` and `78f0be872` in 2 `.js` and 2 `.d.ts` each; - the positive control, the pre-existing sentence 「the object-field face enforces」, appears in 32 files. ## Gates (head `1b885d3c27`) - **Citation judging pass, run as CI runs it:** `pnpm check:issue-citations && node scripts/check-issue-citations.mjs`, both under the grammar PR objectstack-ai#20554 landed, exit 0. The self-test passes 114 cases in 8 batteries. The live, diff-scoped run judged 13 citations across 11 files: 3 resolve and 10 are declared cross-repo references. It reads "every citation this change adds resolves". - **Doc authoring:** `pnpm check:doc-authoring` exits 0. Its 16,759 customer-facing strings across 1,174 spec sources carry no internal issue id, and the sibling-package prose-id baseline holds with no growth. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at this head derived 112 families, and all 112 exit 0. `--ran` reports 112 run, 0 NOT MEASURED, 0 unrun. A full `turbo run build` of `./packages/*` at this head ran first, under the shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate met an unbuilt prerequisite. - **Five roster gates the derivation flags for this diff** (their rosters sit in `.changeset/` or `packages/`, so their silence proves nothing): `node scripts/check-changeset-fixed.mjs`, `pnpm --filter @objectstack/spec run check:spec-changes`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`. All exit 0. - `pnpm --filter @objectstack/spec run check:generated` (derived) reads `All 15 generated artifacts are up to date`, and `check:docs` reads `226 generated files in sync`. - **Tests and typecheck, under the lock:** - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui src/meta-spelling`: Test Files 98 passed (98), Tests 3452 passed (3452), VERDICT command-exit 0 (the chain held the lock 142s on a shared box). - The 16 spec suites outside `src/ui` that read the touched files' source text or pin their lines: Test Files 16 passed (16), Tests 489 passed (489). They are `scripts/{export-origins,file-description,root-index,schema-closure,skill-map-guards,strictness-ledger}.test.ts`, `src/ai/tool-confirmation-prescription-tense.pin.test.ts`, `src/api/api-entry-graph.pin.test.ts`, `src/contracts/scoped-context.test.ts`, `src/data/filter-subtree-provenance.test.ts`, `src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence,union-author-message-pins}.test.ts`, `src/system/constants/platform-object-names.test.ts` and `src/type-alias-convention.pin.test.ts`. Three more suites matched the reader scan and are not run here: `scripts/build-schemas-check-mode.test.ts` only imports `ViewItemSchema` (code the guard proves unchanged) and rebuilds schemas in a temp tree; `scripts/def-key-collisions.test.ts` names `ui/view.zod.ts` only in a comment; `scripts/published-projection-choke-point.test.ts` matched on `build-react-blocks-contract.ts`, not a touched file. They are left to CI. - `pnpm --filter @objectstack/spec typecheck`: exit 0, including `check:test-typecheck` (53 files, 251 errors, 138 pinned signatures held). The same three runs also passed, with the same counts, on the pre-merge tree. - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 25 touched `.ts` files gives 25 files, 0 errors and 0 warnings. All 25 are in eslint's own population (`isPathIgnored` is false for each, read through eslint's API). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, which its own lines 327 to 328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Merge probe:** a `merge-tree` of the head onto `origin/main` `f572a7eb3c`, from a bare shared clone with no merge driver registered, exits 0 (2026-09-29T04:15Z). ## Acceptance notes - **Base and merge.** The branch forked from `487a7846df`, one commit past the claim's stamp `6154165484` (PR objectstack-ai#20551, outside the surface). `origin/main` then moved three commits, and `199002b3e4` (PR objectstack-ai#20554) changed `scripts/check-issue-citations.mjs`, so the gate derivation read STALE TREE. `origin/main` `dee9b26f6c` was merged in (`1b885d3c27`): a clean merge with no driver-routed path and no lockfile change, touching none of this diff's files. The PR's delta against `dee9b26f6c` is exactly its 27 files. `origin/main` has since moved one more commit, `1c761c0d71` (PR objectstack-ai#20565, tests in two other packages), which touches none of them. - **One site beyond the hand-over's list.** The hand-over named `manifest-collection-spelling.ts:71` (`pre-objectstack-ai#10194`). The same comment's first line, `:67`, cites `objectstack-ai#10485`, which also answers 404, and it is rewritten too. The claim names this file; the fix is the same defect class, mechanical in the form stages 1 to 4 fixed, in a file no other claim holds, under the same gates. Reverting it would be one line. - **Open PRs, re-read at 2026-09-29T04:22Z:** 8 open PRs, and none touches any file in this diff. The one that touches `ui/` is objectstack-ai#20570 (objectstack-ai#20446's), on the excluded `view-grouping-query.ts`. The in-flight `Claim:` comments on the 12 `pm:dispatched` cards were read too: only objectstack-ai#20446's names a `ui/` file (`view-grouping-query.ts`, excluded above). - **Hypothesis 2, measured.** In `ui/`, 14 sibling-qualified pairs leave the second number bare: 13 on one line (`+`, `and`, `,`, `/` or `'s PR` between them) and `component.zod.ts:3478` to `:3479`, split across a line break. In 3 of them the second number answers 404 here and the sentence attributes it to objectui (`objectstack-ai#6111` once, `objectstack-ai#8758` twice); they are respelled above. In the other 11 the second number answers 200 here, so it is judged as this repository's and left: `action.zod.ts:1603`, `component.test.ts:2052`, `:2199`, `:2315`, `component.zod.ts:3276`, `:3793`, `:4812`, `expression-bindable-text-keys.zod.ts:33`, `page.test.ts:696`, `react-blocks.ts:256` and `widget.zod.ts:34`. The PR objectstack-ai#20554 header measured `,` and `and` pairs as naming this repository's number and `/` pairs as mostly, but not always, the qualifier's. Whether any `/` pair here names objectui's number is not measured; a 200 here cannot tell. - **What stays in this population: 24 dead sites** (21 under the landed grammar). - **21 test strings**, left as tokens (vitest `it` / `describe` titles in 7 test files): `objectstack-ai#6276` ×6, `objectstack-ai#11658` ×3 and `objectstack-ai#11507` ×1 in `component.test.ts`; `objectstack-ai#9972` in `component.test.ts:411`; `objectstack-ai#17987` ×4 in `component-element-navigation-17987.test.ts`; `objectstack-ai#18177` ×2 in `bulk-action.test.ts`; `objectstack-ai#9933` in `view-metadata-schema.test.ts:406`; `objectstack-ai#11284` in `react-blocks.test.ts:147`; `objectstack-ai#11753` in `action-param-carryover.test.ts:17`; `objectstack#11195` in `view.test.ts:3396`. None is a Zod `.describe()` text, an exported string or a migration-entry field, so no form D site arises here. - **3 comments that name objectui's live PR objectstack-ai#8758 in prose** (`objectui PR objectstack-ai#8758`): `view.zod.ts:2354`, `:2579` and `view.test.ts:426`. They are not pairs, and the landed grammar reads them as objectui's. - **Left for later stages of objectstack-ai#20234** (the stage-4 landing comment 5882686893's list, unchanged): the migrations area, the `liveness/**` notes, the `why` strings and the `PROVENANCE_WAIVERS` reason, `rest-server.zod.ts`, the held `analytics*` and `driver/turso.*` files, the 2 `AGGREGATION_CASES` note strings, and `data/`'s test strings and deliberate markers. - **The same rot outside `packages/spec/src`** is objectstack-ai#20556's, not this card's. Examples met here: ADR-0087's own amendment heading (`docs/adr/0087-metadata-protocol-upgrade-contract.md:390`, `:397`) cites the dead `objectstack-ai#18003`, and ADR-0126 cites `objectstack-ai#11703` and `objectstack-ai#11753`; both are governed. `packages/spec/scripts/strictness-ledger.test.ts:375`, `:380` cite `objectstack-ai#9933`, and `check-objectui-pin-citations.ts` cites `objectstack-ai#10274` and `objectstack-ai#9972`. - **Unchanged wording.** `react-blocks.test.ts:140` says the 2026-08-23 ruling was "recorded on-card". The card is gone, and the changeset of `5383fa670` (now cited on `:139`) records the ruling. The line holds no number, so it is left. - **The citation gate's reach.** It defers `packages/**/*.test.ts`. The 11 touched non-test files are in its judging population. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20367
Clause-②: yes
Implements ruling B on #20367 (
Ruling-ref: 5869334748): one authoring shape.os validateandos buildnow refuse a default export that no stack producer built, andcomposeStacksrefuses an input no producer built. The sixSTACK_*cross-field refusals (capability, cross-reference, namespace prefix, single app, hierarchy scope, trigger capability) therefore reach both doors by construction, not only when the author happened to calldefineStack().What changed
spec (
@objectstack/spec)src/stack-provenance.ts(new): a non-enumerable, non-writableSymbol.for('objectstack.stack.provenance')mark. The precedent isdata/filter-subtree-provenance.ts. Stamping is internal to the two producers. The one published predicate ishasStackProvenance(value), re-exported fromstack.zod.ts.stack.zod.ts:defineStackstamps its return value in both strict andstrict: falsemode.composeStacksstamps its artifact at every arity. Its step 0 refuses unbuilt inputs withSTACK_PROVENANCE_MISSING(422), naming each refused input. This runs first, ahead of the single-input early return. The cross-field validators are unchanged and stay inside the producer.api/error-code-ledger.zod.ts: registersSTACK_PROVENANCE_MISSINGunder@objectstack/spec(emitted bycomposeStacks) and under@objectstack/cli(emitted by the doors). One condition, two emitters, following theENVIRONMENT_NOT_FOUNDprecedent. The:1323-1328comment is left as written. The family is now raised by both doors through provenance.stack-config-default-export-unbuilt-refused, plus the regeneratedregistry.ts,api-surface/root.json,export-origins/root.jsonand two reference docs pages.cli (
@objectstack/cli)utils/config.ts:loadConfigreads the mark off the default export before the named-export merge, which is a spread and drops the mark. It exposes the result asLoadedConfig.stackProvenance.utils/stack-provenance-refusal.ts(new):refuseUnbuiltStackthrows aSTACK_PROVENANCE_MISSING/ 422 error with the prescription to wrap the export indefineStack(...).commands/validate.tsandcommands/compile.ts: step 1a, directly after load and ahead of every other judgement. The throw lands in each command's existing catch-all, so the--jsonenvelope keeps its shape:valid/success,error,code,warnings,conversions. This is the same envelope adefineStackrefusal raised at load already reaches. PR fix(objectql,cli): os validate refuses a view container whose name disagrees with its object, as boot does (#20331) #20391's step 2c is onmain, and this step sits above it in the sametry, feeding the same catch-all envelope.test/validate-build-gate-parity.test.ts: the roster gains a row forrefuseUnbuiltStackinSHARED_NON_REGISTRY_GATES.create.ts, thegenerate.tsfield-type comment, and theenvironment-routing.mdxsentence about spread configs.Premise test (ruled to be run first)
The premise was that host-shaped exports (a
pluginslist of instantiated plugin objects, i.e. theos serve/os migratehost configs) do not go through these two doors. Measured on this tree, it holds for those host configs:os serve,os migrateandos doctor. None of them runsos validate,os buildoros dev, and every one of them passes unchanged in the unit, integration and nightly tiers.examples/app-showcase, which is authored throughdefineStack. It carries the mark and passes both doors.os buildat exit 0 before this change. It is now refused like any other unbuilt export, and the prescribed fix works:defineStack({ manifest, plugins: [instance] })is accepted by both doors. That row is pinned.Pins (
test/stack-provenance-door.test.ts, integration tier, both doors,code+ exit)os validate --jsonandos build --jsonrequires: ['no-such-capability']) asdefineStack({ … })STACK_CAPABILITY_UNKNOWN, exit 1STACK_PROVENANCE_MISSING, exit 1; the prescription's first sentence is asserted;os buildwrites no artifactSTACK_PROVENANCE_MISSING, exit 1defineStack({ … }){ ...defineStack(…), api: {} }STACK_PROVENANCE_MISSING, exit 1defineStack+ named exportonEnablepackages/spec/src/stack-provenance.test.tspins the rest:Object.keys,JSON.stringifyor the strict schema;falsefor a literal, a spread copy, an assign copy, a JSON copy or a structured clone;composeStacksrefuses first, names every input and refuses a lone input;Examples: the echo from route D does not reproduce. Measured with the built CLI at the merged head:
os validateos buildexamples/app-crmvalid: truesuccess: trueexamples/app-todovalid: truesuccess: trueexamples/app-multi-packagevalid: truesuccess: trueexamples/app-showcasevalid: truesuccess: trueThe build door is also held in CI: each example's
buildscript isobjectstack build, and the rootturbo run buildran all four green (Tasks: 73 successful, 73 total).Verification record
scripts/ablation-replace.mjs:if (loaded.stackProvenance) return;becamereturn;, landing confirmed by anchor 1 → 0 and blob5ce32a82→d78f0948. The door test's plain-object rows went red (4 failed; under the mutation the plain defective stack answeredcode: undefinedat exit 0 and the plain host shape built at exit 0, which is the original defect). The file was restored byte-identical: its blob equals theHEADblob andgit diff HEADis empty. The green leg is the full integration run atHEAD.@objectstack/spec:vitest run, 602 files, 17349 passed.@objectstack/cliunit: 233 files, 3336 passed.@objectstack/cliintegration: 62 files, 533 passed, 1 skipped.@objectstack/clinightly e2e tier (OS_TEST_TIERS=nightly): the 18 files this change reddened or touched are green, 96 + 57 + 6 tests across three runs. The first full nightly run (17 red files) is what named them.composeStackstest inputs in@objectstack/metadata,@objectstack/runtime,@objectstack/plugin-devand@objectstack/plugin-securityare green (1 + 3 + 1 + 1 files). Filter direction:composeStacks/os validate/os buildcallers found bygit grepoverpackages/**. No non-test caller ofcomposeStacksexists outsidestack.zod.ts; every other hit is a comment.pnpm --filter @objectstack/cli --filter @objectstack/spec typecheck, exit 0, includingcheck:test-typecheck.pnpm --filter @objectstack/spec check:generated --fixrewrote only the three it proved stale (api-surface, export-origins, docs). All 15 were green on re-check.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 123 commands at head0360658. 122 exited 0. One is NOT MEASURED:node scripts/check-plugin-teardown-shape.mjs --self-testexited 3 on the shallow clone, because its pinned fixture commit is unreachable; it is checker-health only.--ranreconciliation: 123 accounted, 122 run, 1 NOT-MEASURED. Four gates first exited 3 for lack of a full build (check:skill-examples,check:dual-build-cjs-loads,check:i18n-coverage,check:type-check-debt). They were re-run after the full build and exited 0.Fixture census
The dispatch estimated "63 CLI test files". The instrument used here is the suite's own reds after the change, across all three tiers, plus
git grepforcomposeStacks(inputs:requires-retired-capability.e2e.test.ts. Each fixture was rewritten todefineStack(…)with spec linked into the temp dir through the newtest/helpers/define-stack-fixture.ts.defineStackreturned: the mark survives in-place mutation, so this is the reachable route to the composer guards.composeStacksinputs.Fixtures for
os serve,os migrate,os doctorandos lintwere not rewritten. Those doors are outside the ruled surface and do not refuse; this is the host-config premise above.Acceptance notes
requires-retired-capability.e2e.test.ts. An unknownrequirestoken is now the producer'sSTACK_CAPABILITY_UNKNOWN, exit 1, at both doors. The retired token's spec-owned prescription is asserted verbatim in the refusal message, and the typo hint is asserted to appear exactly once, for the misspelled token.--strictpins. These arevalidate-json-failure-conversions.e2e,build-json-failure-conversions.e2eand the [finding] the conversions-only exit-code cell ofos validate --json --strictis documented but untested — every fixture raises zero conversions #11301 cell ofvalidate-json-strict-exit.e2e.defineStackapplies every ADR-0087 D2 conversion itself at load, in either mode, and reports it on stderr. That leaves the doors' own step-2 sink nothing to convert on any accepted config, soconversionsis[]on every exit and--strictdoes not fail on a retiring conversion. This was already true for everydefineStackconfig before this PR. The pins now assert[]plus the live notice on the producer's stderr line, matched by conversion id and path. The loss is raised as an open question in the report, not fixed here.os devcompiles throughos build, so it refuses an unbuilt export when it compiles.os serve,os migrate,os lintandos generatestill load unmarked configs, because the ruling scopes the refusal toos validateandos build. Thegenerate.tscomment now says so rather than claiming the door is gone everywhere.defineStack(…, { strict: false }): schema errors the door must report, rule findings, the conversion fixtures. Valid fixtures use strictdefineStack.registry.tsregion (theregistereddisposition the breaking changeset needs), test inputs in three other packages, and the stale comment incapability-preflight.test.ts. Each is required by the change itself.main.mainwas merged into this branch at6e3e546before this PR opened.Generated by Claude Code