fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) - #20551
Conversation
…low secret at arm time and at registration ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification. ApiTrigger.start() now throws, naming the flow and config.secret, before it stores a hook or subscribes a consumer; the armed hook's secret is required by its type, so handleRequest verifies every post. AutomationEngine.registerFlow refuses the same binding at the publish seam, reading the binding the engine would hand the trigger. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
…ixture its per-flow secret The fixture pins the ledger-disabled refusal on the api entry path; an api flow now registers only with a config.secret (ADR-0041), so the fixture declares one. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 9a2dc358228242b8aada7f4c41a3acf6c575e1ff && git checkout 9a2dc358228242b8aada7f4c41a3acf6c575e1ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba5927f714af7516105706b36a05cedf34d5fa1b b73251341bdcb5d4d345a75af0a7602fdbe669af && git checkout -B drift-repro ba5927f714af7516105706b36a05cedf34d5fa1b && git merge --no-ff b73251341bdcb5d4d345a75af0a7602fdbe669af
node scripts/docs-audit/affected-docs.mjs --json ba5927f714af7516105706b36a05cedf34d5fa1b
|
Contract reviewServed-tier: Card #20529 ( Check-runs on the head, latest run per check name, read at 2026-09-29T02:29:36Z: 34 names — 31 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, named and judged:
② Semver level
③ Boundary flagsThe report's
Disclosure: this is a Implemented-by: VERDICT: PASS Rendered by an isolated contract-review subagent and adopted by the
Generated by Claude Code |
…mes a known repository, so pre-#N / post-#N are judged and framework#N is this repository (objectstack-ai#20554) Fixes objectstack-ai#20330 Clause-②: no ## What changed `scripts/check-issue-citations.mjs` read ANY `word#N` as a repository reference, so `pre-objectstack-ai#12248`, `post-objectstack-ai#6640`, `Pre-#N`, `POST-#N` and `Framework#N` were classed cross-repo and never judged. Its qualifier is now a **closed set**. A token joined to `#N` names a repository only when it is an `owner/repo` form or a name in the new `KNOWN_REPOSITORIES` table, matched case-insensitively. Any other prefix is prose, and the number after it is this repository's and is judged. One recogniser, `repositoryOf`, is asked in all three places: by extraction, by the board's probe set (`boardWanted`) and by the classifier (`namesThisRepository`). The three can no longer disagree about a token. - `KNOWN_REPOSITORIES` rows: `objectstack` and `framework` (both THIS repository), `objectui`, `ui` (an objectui alias, joined form only), `cloud`, `hotcrm`, `hotcrm-heimao`, `os-tianshun-mtc` and `os-project-titanwind-ehr`. Each row carries the measurement that put it in. - **Prose form.** `objectui PR objectstack-ai#10264`, `objectui objectstack-ai#2670`, `cloud objectstack-ai#2937` and `framework objectstack-ai#2679` read as their repository. The form is a known repository name, whitespace, an optional `PR` or `issue`, then `#N`. The alias `ui` is excluded from it, because `UI #N` is ordinary English. - **No carry across a pair.** In `objectui#6110 + objectstack-ai#6111` the second number stays this repository's. The convention is to qualify each number, and the one live site in the claim's surface is respelled: `packages/spec/src/data/field.zod.ts:370` now reads `objectui#6110 + objectui#6111`. This is comment-only, with a `patch` changeset. - **Ordinal heads.** Closing the set exposes ordinals that were hidden behind a fake qualifier. `NON_CITATION_HEADS` gains `OQ` (`ADR-0076 OQ#10`, 10 sites) and `PKCS` (`PKCS#11`, 1 site). A hyphen joining a head to its `#` is now read as the same head (`Prime-Directive-objectstack-ai#12`, 2 sites). `PD#12` (8 sites) was already covered by the existing `pd` head once its candidate is refused. - **Refusal text.** The `REMEDY` text now states the grammar that judged the author. ### A false red in the same seam, fixed because this change would have widened it `buildBoard` probed only UNQUALIFIED numbers, so a diff adding `objectstack#N` was classified against a board that never asked about N. Reproduced on unmodified `288611e3e5`: I appended `objectstack#20330` (this live card) to a swept file and ran `node scripts/check-issue-citations.mjs`. It exited **2**, reading `board: probed (0 citations)` and `[allocated-but-absent] ... objectstack#20330`. Reading `framework#N` as this repository would have inherited that false red on every site. The probe set is now `boardWanted`, meaning every citation judged here. The self-test pins it through `probeBoard` over a stub. The blocking rule is unchanged: findings still exit 2. ## Measurements the design rests on - **`framework` names this repository.** `git ls-remote https://github.com/objectstack-ai/framework` answered HEAD `288611e3e5`, identical to `objectstack-ai/objectstack`. The controls diverged: a nonexistent name under the same owner exited 128, and `objectstack-ai/objectui` answered its own HEAD `0eb9f36aca`. The REST and web routes to `framework` answered 403 from this session's proxy (bound to configured repositories), so git's rename redirect was the readable instrument. `framework#N` / `Framework#N` therefore read as THIS repository. - **Qualifier census on `288611e3e5`, over the declared surfaces.** There were 27 distinct candidates behind 1,655 sites: - This repository: `framework` 255, `objectstack` 111, `objectstack-ai/objectstack` 9, `Framework` 2. - Siblings: `objectui` 672, `cloud` 213, `hotcrm` 24, `objectstack-ai/objectui` 12, `ui` 11, `objectstack-ai/cloud` 9, `better-auth/better-auth` 3, `os-tianshun-mtc` 2, and 1 each of `hotcrm-heimao`, `os-project-titanwind-ehr`, `objectstack-ai/objectos`, `objectstack-ai/ats` and `objectstack-ai/hotcrm`. - Prose: `pre-` 284, `post-` 12, `Pre-` 4, `Post-` 4, `PRE-` 1 and `POST-` 1. - Ordinals: `OQ` 10, `PD` 8, `Prime-Directive-` 2, `PKCS` 1. - **`ui` is objectui.** `objectstack-ai/ui` does not exist, and `ui#6837`, `ui#6206` and `ui#6207` are objectui's records on its board (`objectstack#6206` answers 404, so it would have been a false death). - **Prose form, 27 sites.** For every objectui number I read objectui's board and this repository's. The objectui record is the one each sentence describes: `objectui objectstack-ai#2670` is "Flow designer: render loop / parallel / try_catch as nested", cited from `loop-node.ts`, and `objectui PR objectstack-ai#4264` diagnoses a path on the right side of `==`, cited beside `PATH_SHAPED_LITERAL`. This repository's same number is unrelated on every site. The `cloud` sites could not be read (private) and follow their context. There were zero false positives. - **Pair carry, 48 sites. The measurement refuses a carry rule.** - `,` and `and`: every cross-repo pair I could judge names THIS repository's second number. `cloud#1013 and objectstack-ai#10645` is this repository's cli `serve` issue (4 sites), `cloud#1020, objectstack-ai#5233` its org gate issue (6 sites), `objectui#2561, objectstack-ai#3021` its lazySchema PR, and `objectui#3136 and objectstack-ai#14492` answers 404 on objectui. - `/`: mostly carries, but not always. `objectui#3226 / objectstack-ai#4827` is this repository's objectstack-ai#4827, a conversion entry handed over from objectui and cited from `conversions/registry.ts`. - `+`: exactly one distinct pair exists in the corpus, which is too thin to establish a convention. ## Census of the newly judged spellings Taken with the gate's own `--census --json` at `a3c14755f8` against an enumerated board (184 pages, frontier objectstack-ai#20551). The per-site transition comes from the gate's `--list` before and after the change. Dead means `allocated-but-absent`. Four of the numbers (14657, 12998, 10194 and 8692) were re-probed directly and answered 404. | spelling | sites now judged | dead | |---|---:|---:| | `pre-#N` | 284 | 26 | | `framework#N` | 255 | 0 | | `post-#N` | 12 | 0 | | `Pre-#N` | 4 | 1 | | `Post-#N` | 4 | 0 | | `Framework#N` | 2 | 0 | | `PRE-#N` | 1 | 0 | | `POST-#N` | 1 | 0 | | **total** | **563** | **27** | Other readings, same run: - **Newly deferred (25 sites):** the 24 prose-form sites plus the respelled `field.zod.ts:370`. Four of them were base census deaths that were never deaths: `objectui PR objectstack-ai#8758` three times, and the respelled `objectstack-ai#6111`. - **No longer extracted (21 ordinals):** `OQ#10` ×10, `PD#12`/`PD#10` ×8, `Prime-Directive-objectstack-ai#10`/`objectstack-ai#12` ×2 and `PKCS#11` ×1. - **Whole-census tally:** - Base `288611e3e5`: 38,109 judged. resolves 32,202 · resolves-as-pull 1,863 · cross-repo-unjudged 1,535 · allocated-but-absent 2,509. - Branch `a3c14755f8`: 38,088 judged. resolves 32,689 · resolves-as-pull 1,891 · cross-repo-unjudged 976 · allocated-but-absent 2,532. - The board moved between the two runs, so the per-site transition above is the reading, not the tally difference. The cross-repo count reconciles exactly: 1,535 − 563 − 21 + 25 = 976. ## Verification All gates below ran at `a3c14755f8`, the branch head. - **Self-test.** `node scripts/check-issue-citations.mjs --self-test` exits 0 with 114 cases across 8 batteries (base: 73 cases across 7). The new battery `qualifier` (floor 40) pins every spelling both ways: lit on a live number and a FINDING on a dead one for `pre-` `post-` `Pre-` `Post-` `PRE-` `POST-`, and for `framework` `Framework` `objectstack-ai/framework` `objectstack`. It also pins cross-repo even when dead for `objectui` `OBJECTUI` `ui` `cloud` `hotcrm` `objectstack-ai/objectui` `better-auth/better-auth`, and covers: - an unknown word prefix read as prose; - the four ordinal heads; - the prose form, lit and dead, including `PR #N` and `UI #N` NOT being the prose form; - the pair, no carry (dead second number red) and qualified number by number (both deferred); - `boardWanted` and a probed-board resolution of `objectstack#20330`; - registry hygiene. `live-corpus` gains a pin that every qualifier the live corpus keeps names a repository. - **Ablations.** Six mutations went through `scripts/ablation-replace.mjs`, each landing on disk with the anchor count 1 → 0 and the blob changed, each red on its own case, and each restored with blob equal to HEAD `8b6cf12653dd` and `git diff HEAD` empty: - M1: the recogniser returns any bare candidate. The self-test reds on "`pre-` is prose". - M2: the probe set reverts to unqualified-only. It reds on "the board's probe set". - M3: the `framework` row is renamed. It reds on "`Framework` is THIS repository". - M4: the hyphen head is off. It reds on "`Prime-Directive-objectstack-ai#12`". - M5: the prose form is off. It reds on "`objectui PR #N` names objectui". - M6: the `OQ` head is removed. It reds on "`ADR-0076 OQ#10`". - **Diff-scoped verdict** (`node scripts/check-issue-citations.mjs`, as CI runs it): exit 0 on this branch. It judged the respelled line's 2 citations, both `cross-repo-unjudged`. - **One-time end-to-end proof** (no permanent test; injected uncommitted and restored with blob equal to HEAD and `git diff HEAD` empty). I appended `pre-objectstack-ai#12248` (dead) and `framework#20330` (live) to `packages/cli/src/commands/generate.ts` and ran the diff verdict twice: - The branch gate exits **2**. `pre-objectstack-ai#12248` is `allocated-but-absent`, `framework#20330` resolves on a board `probed (2 citations)`, and the respelled pair stays cross-repo. - The `288611e3e5` gate, from a temporary copy, exits **0** with all 4 `cross-repo-unjudged`. That is the hole this closes. - **Census** (`--census --json`): exit 0. It is report-only and never fails. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 97 commands, and all 97 ran. 96 exit 0, including `pnpm check:pm-dispatch-gates`, whose `dispatch-gates.mjs --self-test` passes 1,976 cases. That self-test pins this file's `:204 local-env` declaration line, and every edit here stays below it or is line-neutral. `--ran` reconciliation reads "97 derived famil(ies) accounted for — 96 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it loads every package's built CJS entry, and this box holds no dist for 80+ packages. The diff changes no emitted code. `@objectstack/spec` was rebuilt, and its entry gates (`check:browser-reachable-entries`, `check:entry-nameability`) exit 0. CI's Lint and Repo Gates owns this one. - **Spec package.** `pnpm --filter @objectstack/spec typecheck` returned VERDICT command-exit 0. See the report comment for the test run. ## Acceptance notes - **Dead sites the census hands over, not rewritten here.** One is under `packages/spec/src/**` and is input for the staged sweep on objectstack-ai#20234: `packages/spec/src/meta-spelling/manifest-collection-spelling.ts:71` `pre-objectstack-ai#10194`. The other 26 are outside `packages/spec/src/**`, and no card names them: - `packages/cli/src/commands/generate.ts:1842` `pre-objectstack-ai#14657` - `packages/cli/src/utils/storage-driver.ts:206` `pre-objectstack-ai#6345` - `packages/drivers/driver-sql/src/schema-drift.ts:2458`, `:2474` `pre-objectstack-ai#12998` - `packages/drivers/driver-sql/src/sql-driver.ts:3872`, `:16545` `pre-objectstack-ai#17590` - `packages/drivers/driver-sql/src/sql-driver.ts:18285`, `:18324` `pre-objectstack-ai#12998` - `packages/drivers/driver-sql/src/sql-driver.ts:20095` `Pre-objectstack-ai#12380` - `packages/drivers/driver-turso/src/remote-transport.ts:2624` `pre-objectstack-ai#12380` - `packages/lint/src/validate-searchable-fields.ts:312` `pre-objectstack-ai#8404` - `packages/metadata-protocol/src/protocol.ts:2793` `pre-objectstack-ai#10888` - `packages/metadata-protocol/src/seed-loader.ts:1947` `pre-objectstack-ai#11674` - `packages/objectql/src/action-governance.ts:339` `pre-objectstack-ai#14423` - `packages/plugins/plugin-auth/src/auth-manager.ts:5629` `pre-objectstack-ai#14762` - `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:266`, `:630` `pre-objectstack-ai#8692` - `packages/plugins/plugin-security/src/per-organization-catalog.ts:314` `pre-objectstack-ai#8692` - `packages/plugins/plugin-security/src/permission-set-projection.ts:482` `pre-objectstack-ai#6483` - `packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts:5` `pre-objectstack-ai#14484` - `packages/runtime/src/domains/mcp.ts:364` `pre-objectstack-ai#8726` - `packages/runtime/src/sandbox/body-runner.ts:548`, `:735` `pre-objectstack-ai#14758` - `packages/runtime/src/sandbox/script-runner.ts:440` `pre-objectstack-ai#14758` - `packages/types/src/driver-error-classification.ts:608` `pre-objectstack-ai#13324` - `packages/types/src/node.ts:1428` `pre-objectstack-ai#10943` - **The same `objectui#6110 + objectstack-ai#6111` pair outside the claim's surface.** It still reads `objectstack-ai#6111` as this repository's (404 here), so these are existing census deaths: `packages/spec/src/ui/view.zod.ts:3634` (for the objectstack-ai#20234 sweep), `packages/metadata-core/src/form-predicate-root-policy.ts:14`, `:120` and `:205`, and `packages/metadata/src/plugin.ts:910`. Each respells to `objectui#6110 + objectui#6111`. - **Pairs that read silently wrong, not dead.** Several `REPO#N / #M` pairs name the qualifier's own second number, which resolves here as an unrelated record. Examples: `hotcrm-heimao#35/objectstack-ai#40/objectstack-ai#59`, `objectui#2715/objectstack-ai#2717`, `objectui#2711/objectstack-ai#2722`, `objectui#4648/objectstack-ai#4901`, `objectui#5018 / objectstack-ai#6469`, `cloud#957 / objectstack-ai#962` and `cloud#930/objectstack-ai#944`. No gate can see these, because they resolve. The convention in the refusal text (qualify each number) is the remedy when someone next touches the line. - **Seat 4's `objectui PR objectstack-ai#10264` specimen.** `packages/spec/src/api/export-job-family-retirement.test.ts:25` sits on a DEFERRED surface (`packages/**/*.test.ts`), and `surfaceFor` answers `null` for it. The census never judged that site. The prose form it names is now read correctly wherever the census does look. - **Observed once: a truncated board enumeration accepted as a reading.** My first branch `--census` read `enumerated (126 pages)` with frontier objectstack-ai#13977, against 184 pages and objectstack-ai#20551 on the re-run minutes later, and reported 9,160 `never-issued` phantoms. `enumerateBoard` stops at the first page without `rel="next"` and trusts the maximum it saw as the frontier. This diff does not touch that code. The diff-scoped verdict enumerates only past 400 distinct numbers. Recorded, not filed; the seat decides. - **Scope declaration.** `NON_CITATION_HEADS` (two rows) and `nonCitationHead` (the hyphen) are grammar next to the qualifier, not the qualifier itself. They are here because closing the qualifier made those 13 ordinal sites judged citations of this repository's objectstack-ai#10, objectstack-ai#11 and objectstack-ai#12, which is false. No gate was added, and the diff-scoped blocking rule is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- 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>
…bjectstack-ai#20593) Fixes objectstack-ai#20553 Clause-②: yes (narrowing — `os validate` / `os build` / `os lint` newly refuse a secretless `api`-bound flow; the new exported rule id `FLOW_API_TRIGGER_SECRET_MISSING` widens `@objectstack/lint`) This PR is the `os validate` half of the card. Triage split the skill half out to objectstack-ai#20569, which stays open and is not addressed here. ## What this changes `packages/lint/src/validate-flow-trigger-readiness.ts` gains one rule id, `flow-api-trigger-secret-missing`, at severity `error`. It names a flow bound to the inbound `api` trigger when the flow's start node carries no usable `config.secret`. - **Usable secret.** This is the runtime's judgement, read the same way: a string that is non-empty after `trim()`. The rule fires for a missing, blank or non-string secret. It also fires for an `api` flow with no start node, because the engine reads that flow's `config` as `{}` and refuses it too. That finding is located at `flows[i].nodes`. - **`status` is not read.** The engine refuses an `obsolete` flow as well. - **What the finding says.** It names the flow, the declaration that binds it (`type: 'api'` and/or a start-node `triggerType: 'api'`) and what is wrong with the secret. It gives the type of a bad value only, never the value, because findings travel into CI logs (and, once objectstack-ai#20611 moves the rule onto the runtime publish gate, into that gate's responses). - **What the hint says.** It prescribes a non-blank `config.secret` plus signing with `x-objectstack-signature`. For a flow that is only ever started explicitly, it prescribes `type: 'autolaunched'` with no `triggerType: 'api'`. - **Severity: `error`, in the file's never-fire family.** The question the family's Severity section asks is whether this stack alone is enough to know the flow is dead. Here the verdict is `registerFlow`'s own hardcoded refusal, which runs before any trigger is consulted. So I measured the "installing something fixes it" hypothesis, and it is false. An engine with a registered `api` trigger that would arm anything still refused every secretless shape below. - **Rule id.** The name follows the file's `flow-DESCRIPTOR-VERDICT` convention: the descriptor is the `api` trigger's secret, and the verdict is "missing". - **Gate-required edits.** - `index.ts` re-exports `validateFlowApiTriggerSecret` and `FLOW_API_TRIGGER_SECRET_MISSING`. `rule-id-barrel-exports.test.ts` requires every rule id to be reachable from a published barrel, and the wiring guard requires every exported rule to be registered. - `authoring-rules.ts` gains the registry entry `validateFlowApiTriggerSecret` (`tier: 'gating'`, all three commands, `surfaces: CLI_ONLY` with a `surfaceReason`) and updates its family comment, which said "Four rules answer yes and emit `error`". - Four `content/docs` CLI transcripts quote `Running author-time rules (N)...`. The new entry takes the registry from 46 to 47, and `check:docs-transcript-drift` holds each quote to `authoringRulesFor(cmd)`, so exactly those four lines move to 47. - **Changeset.** `.changeset/20553-validate-api-flow-secret.md` bumps `@objectstack/lint` `minor`. ### Which flows are `api`-bound: the engine's binding, not a reading of `type` `bindsApiTrigger` is the engine's `deriveTriggerBinding`, in its own order: 1. The array-form record `triggerType` pre-check. This is the same predicate as this file's `isArrayRecordTriggered`. 2. Otherwise `resolveFlowTriggerKind(flow) === 'api'`. This is the spec export the file already reads. I measured it on the built `AutomationEngine.registerFlow` at `f11b5f20a2`, with a scratch script (deleted afterwards) and a recording trigger registered for each kind. The script compared the engine against two candidate derivations over 15 shapes: | shape | engine | this rule's derivation | `type === 'api' OR triggerType === 'api'` | |---|---|---|---| | `type: 'api'`, no / blank / non-string secret | refused | bound | bound | | `type: 'api'`, secret | registered, `api` started | bound (passes) | bound | | `autolaunched` / `screen` / `record_change` + `triggerType: 'api'`, no secret | refused | bound | bound | | `type: 'api'` + scalar `timeRelative: 'daily'` | refused | bound | bound | | `type: 'api'`, `obsolete`, no secret | refused | bound | bound | | `autolaunched`, neither | registered | not bound | not bound | | `type: 'api'` + `config.schedule` | registered (no secret asked) | not bound | **bound** | | `type: 'schedule'` + `triggerType: 'api'` | registered (no secret asked) | not bound | **bound** | | `type: 'api'` + `record-after-create` | registered, `record_change` started | not bound | **bound** | | `type: 'api'` + array record token | registered, `record_change` started | not bound | **bound** | | `type: 'api'` + `timeRelative` object | registered (no secret asked) | not bound | **bound** | The composed derivation agrees with the engine on all 15 shapes. The disjunction disagrees on the 5 bold precedence shapes, and would refuse flows the engine registers. A start-less `type: 'api'` flow was measured separately: the engine refused it with the secret error. ### Why the rule carries the check instead of reading the runtime's - **Two runtime copies.** The judgement lives in `AutomationEngine.validateApiTriggerSecret`, a private method in `packages/services/service-automation/src/engine.ts` called from `registerFlow`. It also lives inline in `ApiTrigger.start()` in `packages/triggers/trigger-api/src/api-trigger.ts`. - **No spec predicate.** I searched for one, and `@objectstack/spec` exports none for the secret. The only spec hits are outbound-webhook signing keys. The spec does export the kind half, `resolveFlowTriggerKind`, and the rule reads it. - **Dependency direction.** This package depends on `@objectstack/spec` only, never on a runtime. - **So the rule carries the one-line judgement.** The new rule id's docblock names both runtime copies and says why neither can be read from here. ## Verification record (HEAD `afa9e266fd`; the premise, corpus and first two ablations were measured at `29caa84eb3`) **Round 3, at `afa9e266fd` — the rule is CLI-only until objectstack-ai#20611.** `flow-api-trigger-secret-missing` moved into its own exported rule, `validateFlowApiTriggerSecret`, on its own `CLI_ONLY` registry entry. `@objectstack/lint`: 115 files, 5379 passed; typecheck exit 0. `@objectstack/metadata-protocol`: 189 files passed, 3 skipped (2768 tests passed, 19 skipped), including objectstack-ai#20552's two round-trip pins in `protocol.metadata-redaction.test.ts`, which failed while the id sat on the runtime gate. `service-automation` (7 files, 45), `metadata-service` (72) and runtime `automation-flow-credential-projection` (7) pass. CLI consumers (36 files): unit 12/257, integration 6/82 + 6/42, nightly `.e2e` 6/70 + 6/41. The built CLI prints "Running author-time rules (47)": a secretless probe exits 1 with the finding, a signed one exits 0. `dispatch-gates` derived 89 commands, all exit 0 (two answered PREREQUISITE NOT MET first and passed after building what they named); `--ran`: 89 derived, 89 run, 0 NOT-MEASURED. **Premise, measured first, at `origin/main` `f11b5f20a2` (unmodified tree).** - **Card's re-check.** `git grep -c secret -- packages/lint/src/validate-flow-trigger-readiness.ts` gave no output with exit 1, i.e. 0 hits. The lit control `git grep -c triggerType` on the same file answered 39. - **Instrument.** The built CLI, `node packages/cli/bin/run.js validate objectstack.config.ts`. I ran it on a throwaway stack, deleted afterwards, under `examples/app-showcase/.probe-20553/`. The stack had `requires: ['automation', 'triggers', 'queue']` and one flow: `type: 'api'`, `status: 'active'`, `runAs: 'system'`, start `config: { hookId: 'intake' }`. - **Before.** The CLI printed "Running author-time rules (46)" and `✓ Validation passed`, exit 0. A start-less variant also passed, exit 0. - **After, at `29caa84eb3`.** - The same stack gave `✗ Author-time rules failed (1 issue)`, `rule: flow-api-trigger-secret-missing at flows[0].nodes[0].config.secret`, exit 1. - The start-less variant exited 1, at `flows[0].nodes`. - The same stack with `secret: 'whsec_probe'` gave `✓ Validation passed`, exit 0. - **Runtime publish gate, at `afa9e266fd`.** The rule's own registry entry is `surfaces: CLI_ONLY`, so the gate does not reach it. `runRuntimeAuthoringRules({ type: 'flow', item })` from the built `@objectstack/lint/runtime` gave `errors: []` for a secretless flow; `rulesRun` held `validateFlowTriggerReadiness` but not `validateFlowApiTriggerSecret`. At `29caa84eb3`, before the split, the same call gave `errors: [["flow-api-trigger-secret-missing","flows[0].nodes[0].config.secret"]]` — the behaviour that broke objectstack-ai#20552's round-trip pins once objectstack-ai#20552 landed. **Build.** - CLI closure: `turbo run build --filter='@objectstack/cli...' --concurrency=2`, 59/59 tasks. - `pnpm --filter @objectstack/lint build`: exit 0, and `check-dts-emitted` reported 4/4. - Showcase closure: `--filter='@objectstack/example-showcase^...'`, 60/60 tasks. - Every build went through `os-verify-lock.sh` and printed `VERDICT command-exit 0`. **Tests.** Counts at `afa9e266fd` unless marked. - `@objectstack/lint`, whole package: 115 files, **5379 passed** (5373 at `29caa84eb3`; the 6 added are the wall pins below). - The rule's file plus `rule-id-barrel-exports.test.ts` and `authoring-rule-wiring.test.ts`: 117 passed. - New cases: - a secretless `type: 'api'` flow fails, checked exhaustively on rule id, severity, `where` and `path`; - the same flow with a secret passes; - an `autolaunched` flow passes; - a start-node `triggerType: 'api'` on `autolaunched`, `screen` and `record_change` flows is judged like `type: 'api'`, and passes with a secret; - blank `' '`, `''` and a tab-newline secret fail, as do a number, boolean, null, array and object, and the value is never echoed; - a padded real secret passes; - `obsolete` and `draft` flows are judged; - a no-start-node flow is judged; - the five precedence shapes stay silent, and each is paired with the shape that fires; - the id slug is pinned. - one rule id on ONE side of the runtime wall: `validateFlowTriggerReadiness` alone no longer emits it; its registry entry is gating, on all three commands, `surfaces: ['cli']`, with a reason; `os validate` / `os build` / `os lint` each still refuse a secretless flow through the table; and the runtime gate emits no `flow-api-trigger-secret-missing` for it, with a positive control (the same gate still refuses a dead `record_change` flow with `flow-trigger-unroutable`). - The severity map's `provoke` table gains this id as `error`. The clean-stack floor gains a signed `api` flow. - **CLI consumer tests.** Every `@objectstack/cli` test that reaches the validate, build or lint rule table: 36 files at `afa9e266fd` (the merge of `main` added one). None was edited. - `unit` project: 12 files, 257 passed. - Nightly-tier `.e2e` files, run with `OS_TEST_TIERS=nightly`: 6 files / 70 passed, then 6 files / 41 passed. - `integration` project: 6 files / 82 passed, then 6 files / 42 passed. **Typecheck.** `pnpm --filter @objectstack/lint typecheck`: exit 0. - `tsc --noEmit` covers the rule file. - `check:test-typecheck` reported "OK, test layer compiles under tsconfig.test.json", and its debt is unchanged. `tsc --listFiles -p tsconfig.test.json` lists the test file. **Ablations.** The first two ran at `29caa84eb3` on the pre-split code, with `scripts/ablation-replace.mjs` in WRAP mode and an outer `trap` restoring the absolute path; the subject is imported from relative source, so no `dist/` is involved. The third ran at `825c33ff9f` through lint's built `dist/` (`metadata-protocol` → `@objectstack/lint` is a known unaliased pair): with the id dropped at the runtime surface only (marker proven in 4 `dist/` files), `protocol.metadata-redaction.test.ts` passed 26/26; restored (blob == HEAD `789b320b`, `git diff HEAD` empty, marker absent from all 14 `dist/` files), exactly its two round-trip pins failed again (2 failed / 24 passed). - **Ablation 1: the finding disabled.** The `if (secretProblem) {` anchor went from 1 hit to 0, and the blob moved from `4b53700d` to `46f09b8d`. - Result: **8 failed, 73 passed.** All 7 positive cases in the new block failed, plus the `provoke` row. The pass-controls stayed green. - Restored: the blob equals HEAD `4b53700d`, and `git diff HEAD` is empty. - **Ablation 2: the binding swapped for the `type OR triggerType` disjunction.** The anchor went from 1 hit to 0, and the blob moved from `4b53700d` to `341d0408`. - Result: **1 failed, 80 passed.** Exactly the precedence case failed, first at `api + config.schedule`. - Restored: the blob equals HEAD, and `git diff HEAD` is empty. **Gates.** - **Derivation, at `afa9e266fd`.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 89 commands (the four `content/docs` transcripts add 29 docs families). Each was run with its exit code captured before any pipe. - **Result.** All 89 exited 0. - `check:dual-build-cjs-loads` and `@objectstack/spec`'s `check:skill-examples` first answered `PREREQUISITE NOT MET`, exit 3. That is not a measurement. After building the packages they named, both exited 0. - **Reconciliation.** `--ran` gave "89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero, all 89 recorded an exit code)". - **Changeset level axis.** Locally this reads NOT APPLICABLE, because there is no PR payload. I drove it offline with an event file carrying this body's first two lines, and it answered: "this PR declares clause-② `yes (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`". - **`check-adr-0087-registration`.** "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition": `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. Its `--self-test`: 441 assertions. **Lint, narrowed and proven.** eslint `--no-inline-config --format json` over the 4 changed `.ts` files reported 4 files, 0 errors and 0 warnings. Three facts make that narrowing a measurement: - None of the files reported "File ignored". - The count comes from the JSON output. - `eslint.config.mjs` lines 327-328 state that the config never enables type-aware linting, so this diff cannot move an untouched file's verdict. The repo-wide `pnpm lint` is declared to CI. **Corpus sweep, at `29caa84eb3`.** - `os validate` over all 4 example stacks: `app-crm`, `app-multi-package`, `app-showcase` (after building its closure) and `app-todo`. All answered `✓ Validation passed`, exit 0, with 0 hits of the new id. - The repo's one `api`-bound example flow, `showcase_inbound_task_webhook`, carries `secret: 'showcase-webhook-secret'`. That secret predates PR objectstack-ai#20551, which gave no example or fixture a secret. - CLI tests and fixtures declare no `api`-bound flow. A grep for `type`/`triggerType` `'api'` over `packages/cli/test` hit only the `os explain` type-enum doc, which is a `record_change` example. **Pin sweep.** - No test or doc asserts that `os validate` passes a secretless `api` flow. - No catalogue outside `packages/lint` lists this file's rule ids exhaustively. The one non-lint hit, `flow-trigger-kind.ts`, is a docblock mention. - `content/docs` has no "secret optional" line for the inbound trigger. The only hit is `webhooks.mdx` P3, which is about outbound webhooks. **Other checks.** - `grep -naP` for raw control bytes over the 9 changed files found nothing. - The branch merges `origin/main` at `c96beb2707` (objectstack-ai#20552's landing, which surfaced the round-trip conflict) in merge commit `825c33ff9f`. `origin/main` has since moved to `7510663c87`; `dispatch-gates` reports none of those commits touched what its derivation reads. ## Acceptance notes - **Edits outside the original claim surface, admitted by the seat.** `authoring-rules.ts` gains the CLI-only `validateFlowApiTriggerSecret` entry (amended into the claim by the seat's fork ruling), and four `content/docs` transcripts move their quoted rule count from 46 to 47 (admitted as the registry's own quotation; nothing under `content/docs/releases/`). `index.ts` carries the barrel lines the rule-id barrel test and the wiring guard require. - **Claim/dispatch mechanism assumption corrected by measurement.** The claim calls lines `:507` and `:618` "the binding this rule already derives". - `:618` (`routesToSomeTrigger`) is a routes-anywhere disjunction. As an `api` derivation it disagrees with the engine on 5 shapes, per the table above. - `:507` is precedence-ordered, but it is reached only inside 1e. - The rule uses the engine's own two-step derivation instead. Ablation 2 shows the test holds that line. - **Clause-② arm.** The seat ruled `yes (narrowing)`: the new exported rule id widens `@objectstack/lint`, and `os validate` / `os build` / `os lint` newly refuse a stack they used to pass. The changeset carries the line byte-for-byte, a `**BREAKING**` banner (shipped `minor` under the launch-window convention) and the ADR-0087 disposition `not-required (no-migration-prescription)`. The engine's own refusal already shipped in 17.5.0 (PR objectstack-ai#20551's published changelog entry). - **Publish gate: deliberately not covered yet (objectstack-ai#20611).** `saveMetaItem` runs the runtime authoring gate (`protocol.ts:16352`) before it restores the stored secret the flow read path withholds (`:16588`, objectstack-ai#20552), so on that gate a signed flow's GET → edit → PUT arrives secretless. With this id on the gate, `protocol.metadata-redaction.test.ts`'s two round-trip pins failed (measured at `825c33ff9f`; 26/26 with the id dropped there). The id therefore sits on its own `CLI_ONLY` registry entry until objectstack-ai#20611 makes the gate judge the carried-forward body. Meanwhile the `/meta` door behaves as it did before this PR: it stores a secretless flow, and the engine refuses it at registration. The `/automation` write doors call `registerFlow` directly and never reach this gate, so their `400 VALIDATION_FAILED` answer is unchanged. - **Observation, not filed: the engine's wording.** `engine.ts` `validateApiTriggerSecret` answers "declares no `config.secret`" even when a non-string secret is present. The measured case was `secret: 12345`. Carrier: none now that objectstack-ai#20552, which held `service-automation/src/**`, has landed. - **Observation, not filed: a test title.** The existing test "flags schedule and api flows for missing status too" builds only a `schedule` flow. Carrier: none. - **Not measured.** Whether the Studio flow designer (objectui) lets an author set `config.secret` on an `api` flow's start node. The sibling repo is not in this change. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20529
Clause-②: no (narrowing)
What this changes
ADR-0041 (status Accepted),
trigger-apiacceptance 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. Anapitrigger with no secret is now refused at arm time and at registration.@objectstack/trigger-apiApiTrigger.start()throws when the binding'sconfig.secretis absent, blank after trim, or not a string. The error names the flow andconfig.secret. It throws before anything is stored in the hook map and before any queue consumer is subscribed.ArmedHook.secretis now non-optional.handleRequestverifies every post, so the type system has no unsigned branch left to reach.@objectstack/service-automationregisterFlowgainsvalidateApiTriggerSecret, placed after the three existing hard-fail validations.deriveTriggerBindingcomputes. That is the body ofresolveTriggerBinding, split out so it also runs over a flow that is not registered yet. So the rule reads the veryconfigobject thatactivateFlowTriggerwould handstart(), including the array-form precedence.status, like the other registration refusals./automationcreate, update and clone doors answer400 VALIDATION_FAILEDwithdetails.fields[0] = { field: '(body)', code: 'invalid_value' }. This throw has the same plain-Errorshape (the flow-rejected message) thatpackages/runtime/src/domains/automation-register-error-class.test.tscase 4 already pins.[Automation] failed to register flowwarning.packages/specedit.Why the rule lives in two places.
@objectstack/trigger-apiand@objectstack/service-automationhave no dependency on each other. At boot the trigger registers onkernel: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 bindingconfig, so they cannot disagree about which flows need a secret. A single home that also reachesos validatewould be apackages/specrule (see below). That is the spec lane's call and is not made here.Breaking. The changeset
.changeset/20529-api-trigger-requires-secret.mdbumps both packagesminor. Its BREAKING paragraph gives the remedy: set a non-blankconfig.secreton the start node. A flow that is only ever started explicitly istype: 'autolaunched', with notriggerType: 'api', and needs no secret. Its ADR-0087 disposition isnot-required (no-migration-prescription), accepted bycheck-adr-0087-registration.Pin sweep
git grepfor the warning text, "unsigned post" and "accepts unsigned" (CHANGELOGs excluded) hit four places: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.start()throws, naming the flow andconfig.secret;listHooks()is[];armed:log line was written;404with the fullRESOURCE_NOT_FOUNDbody;401missing-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.apiflow with no secret:engine.test.ts: the execution-history fixture changes type toautolaunched. It is only ever run throughengine.execute, never an inbound hook.flow-trigger-kind-shared-resolver.test.ts(thetype: 'api'andtriggerType: 'api'rows) andflow-activation-ledger.test.ts(theapientry path) now declare the secret. Their subject is kind resolution and ledger refusal, so they must stayapi.api-kind flow definitions found no other fixture that reaches a real engine. The only other hits are inpackages/lintandpackages/spectests, which never callregisterFlow.api-trigger-secret-registration.test.ts):type: 'api'flow with no, blank or non-string secret; a start-nodetriggerType: 'api'flow; and anobsoleteflow. Each asserts that the flow is absent afterwards (getFlowis null and it is not in the runtime states) and that theapitrigger was never started.autolaunchedflow needs no secret and runs.Verification record (HEAD
b7325134)turbo run build --filter=!@objectstack/docs --concurrency=2: 72/72 tasks, 0 cached. It was needed for the dist-reading gates.@objectstack/service-automation: 150 files, 1845 tests, all passed.@objectstack/trigger-api: 2 files, 26 tests, all passed.b7325134, after the last commit.@objectstack/trigger-apipasses.tsc --listFilescounts both of its test files.@objectstack/service-automationpasses, includingcheck:test-typecheck.scripts/ablation-replace.mjsreplaced the throw with the oldlogger.warn.7e60a8abtocc123fba.Tests 3 failed | 8 passed (11). All three refusal cases failed withAssertionError: expected [Function] to throw an error.7e60a8ab, andgit diff HEADis empty.validateApiTriggerSecretcall was deleted.679f73ddtoe66c2db2.Tests 6 failed | 2 passed (8). All five refusal cases and the re-registration case failed withexpected [Function] to throw an error. The two contrast cases stayed green.679f73dd, andgit diff HEADis empty.dist/was involved.dispatch-gates --commands, derived over this diff's 9 paths, gave 62 commands. All 62 exited 0. Reconciling with--rangave "62 derived, 62 run, 0 NOT-MEASURED (a DERIVED zero)".check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3, not a measurement). After the full build it exited 0.check:dts-closureandcheck:lean-entry-closurewere re-run after the full build too.--no-inline-config --format jsonover the 8 changed.tsfiles reported 8 files, 0 errors and 0 warnings. Three facts make that narrowing a measurement rather than a skip:packages/**/*.{ts,tsx,mts,cts}blocks, and none reported "File ignored".parserOptions.project;eslint.config.mjsstates this at lines 326–328), so this diff cannot move any untouched file's verdict.pnpm lint, the downstream consumer suites of@objectstack/service-automation, and the full farm.The three measurements
runAs, which defaults to'user'.runAs: 'system'runs its data nodes with system elevation. The shipped worked example declaresrunAs: 'system', because it creates a record.config.secret: yes, by source reading. I reported it to the seat as an out-of-scope security finding. It is not changed here.apiflow is the showcase's worked example, and it carries a secret, soexamples/**needs no edit.packages/create-objectstackdeclares noapiflow. One thing did turn up: the published automation skill describes the secret as optional (reported below).os validatereachNo.
os validatenever builds anAutomationEngineor callsregisterFlow.packages/cli/src/commands/validate.tsruns thedefineStackparse, the@objectstack/lintauthoring rules and the capability preflight. I measured it on a throwaway stack (deleted afterwards) that declares onetype: 'api'flow with no secret andrequires: ['automation', 'triggers', 'queue']:os validateprinted✓ Validation passed, exit 0. #20367 (PR #20460) runs the stack'sdefineStackrefusals, and this refusal is not one of them. Foros validateto see it, the rule would need to be adefineStackrefusal next to the trigger-capability refusal (keyed onresolveFlowTriggerKind), or avalidate-flow-trigger-readinessrule inpackages/lint. Both belong to another lane.Acceptance notes
hookIdfallback 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.skills/objectstack-automation/SKILL.md(lines 52 and 356) calls the secret "strongly recommended" and describestype: 'api'as "invoked explicitly … or bound as an inbound webhook". The engine binds everytype: 'api'flow to the inbound trigger, so an author following it now writes a flow the runtime refuses. The file is Tier H.os validatepasses a flow the engine refuses (measured above).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.mdxsays anapiflow "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".scripts/adr-anchors/entry for ADR-0041 was added. That path is outside this card's file surface.apiflow'sconfig.secretis not measured. The sibling repo is not checked out in this container.examples/**andcontent/docs/**paths this diff never touched.Generated by Claude Code