Skip to content

fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) - #20551

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20529-api-trigger-requires-secret
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20529-api-trigger-requires-secret

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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. #20367 (PR #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

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/trigger-api, touching 7 documentable anchor(s).

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

  • content/docs/automation/flows.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a method of class ApiTrigger))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine))

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

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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

Which tree this was computed on

This run read content/docs from 9a2dc358228242b8aada7f4c41a3acf6c575e1ff — the merge of head b73251341bdcb5d4d345a75af0a7602fdbe669af into base ba5927f714af7516105706b36a05cedf34d5fa1b, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b73251341bdcb5d4d345a75af0a7602fdbe669af
Local-runs: none

Card #20529 (security, priority:p1) · PR #20551 (draft, head repo = base repo, base main, merge-base 288611e, mergeable: true) · 9 files, +326/−39; the PR file list and the net diff against main agree on the set. Inputs: the card's body and all three comments (triage 5881532933, claim 5881845145, os-dev-report 5882379577), the PR body, its file list, the net diff, and the head's check-runs. Read-only: no worktree, build, test or gate run.

Check-runs on the head, latest run per check name, read at 2026-09-29T02:29:36Z: 34 names — 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) — path/opt-in skips, not verdicts on this diff), 0 failure, 0 in_progress. An earlier read at 2026-09-29T02:23:45Z still had Lint & Repo Gates, four Test Core shards and the Dogfood Regression Gate rollup in progress; all six have since concluded success. Check Changeset and Governed Surface Queue Guard are among the successes.

① Derived judgments

Every accept-set and public-surface change the diff implies, named and judged:

  1. @objectstack/trigger-api — ApiTrigger.start() accept set narrows. RIGHT. A binding whose config.secret is absent, blank after trim, or not a string now throws (naming the flow and config.secret) before the hook-map write and before the queue subscribe, so a refused flow leaves no hook and no consumer. The refused set is exactly the set the pre-existing normalisation line already collapsed to undefined and then armed unsigned — nothing that used to arm signed is refused. The start() signature and FlowTriggerBinding are unchanged. Governing text: ADR-0041 (Accepted), trigger-api acceptance criteria, docs/adr/0041-flow-trigger-family.md:117-119 — a per-flow secret, HMAC verification, constant-time compare. The engine's existing bind catch (engine.ts:3690, warn, "Failed to bind flow") already handles a trigger that throws at bind, so a host reaching this path gets the established registered-but-unbound outcome.

  2. @objectstack/trigger-api — handleRequest loses its unsigned branch. RIGHT. Every stored hook now carries a secret, so the branch had no reachable input left; the response set (404 / 401 / 400 / 202 / 503) and every body shape are unchanged. A post to a refused flow answers the same 404 body an unknown flow gets — the no-probing-oracle property is kept and now pinned.

  3. ArmedHook.secret becomes required. RIGHT, and not a published-surface change: ArmedHook is a non-exported interface, unreachable from src/index.ts (which exports ApiTriggerPlugin, HOOKS_PATH, ApiTrigger, verifySignature and the four types FlowTrigger, FlowTriggerBinding, QueueServiceSurface, TriggerLogger). Under the exports-map rule it is shipped bytes, not a published accept set — no Clause-② effect. Correctly left out of the semver reasoning.

  4. Trigger docblock and TRIGGER_API_ROUTE_LEDGER note. RIGHT. Prose only; the ledger table is entry-unreachable and read by its conformance test. The two remaining in-tree statements of the old posture are corrected; the one outside the diff is a Tier H file (③.g).

  5. @objectstack/service-automation — AutomationEngine.registerFlow() accept set narrows. RIGHT. validateApiTriggerSecret runs after the three existing hard-fail validators (validateNodeConfigKeys, validateDecisionModes, validateFlowExpressions) and before the version-history push and this.flows.set (engine.ts:4193-4222), so a refused first registration stores nothing, and a refused re-registration leaves the stored definition, its history and its armed trigger untouched (pinned by the re-registration case). It throws a plain Error; all six registerFlow callers at the head are per-flow try/caught — service-automation/src/plugin.ts:984, :2014, :2059 (boot, re-sync, cold-boot bind — each logs the existing "failed to register flow" warning and continues) and runtime/src/domains/automation.ts:2105, :2397, :2993 (create, clone, update — each maps the throw through flowDefinitionRefusal to 400 VALIDATION_FAILED). No new error code. What callers see is unchanged in kind.

  6. The rule is keyed on the resolved trigger kind, not on type. RIGHT. deriveTriggerBinding is resolveTriggerBinding's body moved verbatim (array-form pre-check, then resolveFlowTriggerKind, then the per-kind switch), so the refusal judges the very binding.config — the start node's config object — that activateFlowTrigger hands start(). Spec's resolver answers api only from type: 'api' or a start-node triggerType: 'api', and only after the record- / timeRelative / schedule precedence, so a type: 'api' flow whose start node carries a schedule resolves to schedule, needs no secret, and binds to that trigger exactly as before; the refusal message's parenthetical is never empty. Both new methods are private, so the entry .d.ts gains only private member names — not a published surface.

  7. Status-agnostic refusal (an obsolete api flow with no secret is refused too). RIGHT. It matches the three existing hard-fail validators, none of which reads status; an obsolete flow re-enabled by toggle would otherwise reach activateFlowTrigger and be refused there with a warn, so refusing at registration is the loud, earlier form of the same outcome. The changeset discloses it ("whatever the flow's status").

  8. hookId 'default' fallback left unchanged. RIGHT. Triage's ⛔ was against a default token standing in for an unset one without a secret; with a secret mandatory for every armed hook, the fallback has no unsigned form.

  9. Pins. RIGHT. The test that encoded the defect is replaced by three refusal cases (missing / blank / non-string), each asserting the substance — a throw naming the flow and config.secret, listHooks() empty, no queue subscribe, no armed: log line, a full-body 404 on a post, nothing published, delivered or run. The 401 missing-or-bad-signature assertions are verbatim; every other case that armed with {} now arms with a secret and signs, assertion unchanged. beforeEach rebuilds the fake queue (with its new subscribed recorder), the trigger and the runs list and clears the mocks, so the "nothing subscribed, nothing logged" assertions are sound. The registration suite asserts absence (getFlow null, not in runtime states, trigger never started) for all five refusals, plus two contrast cases and the re-registration case.

  10. Fixture triage. RIGHT. The engine.test.ts execution-history fixture goes api → autolaunched (its only entry is engine.execute, and its subject is execution history); flow-activation-ledger.test.ts and flow-trigger-kind-shared-resolver.test.ts add a secret rather than change kind, since their subject is the api kind.

  11. Reach of the narrowing across the repo at the head, outside the nine edited files. By source reading the narrowing reds no untouched suite: the only flow-shaped api definition is the showcase inbound flow (examples/app-showcase/src/automation/flows/index.ts:1562-1582), which carries a literal secret; packages/create-objectstack declares no api flow; trigger-api/src/plugin.ts constructs no binding itself; every other type: 'api' hit in packages/** and examples/** is an action, a connector or a metadata item type, not a flow. The live measurement is the full Test Core farm, which is green on the head.

  12. Correctly not changed. packages/spec untouched (the start-node config is an open record; spec carries no secret typing on it, so no published type documents the inbound secret as optional — the secret / signingSecret optionals in spec belong to the outbound webhook and IO-node schemas). content/docs has no inbound-secret wording to correct (every HMAC/secret hit is outbound webhook delivery). No examples/** edit needed.

② Semver level

  • Changeset .changeset/20529-api-trigger-requires-secret.md: @objectstack/trigger-api: minor, @objectstack/service-automation: minor. Both publish — neither is private, both carry an exports map, both are in the 69-member fixed group — and they are the only two packages whose published source moves. Set: RIGHT.
  • Clause-②: no (narrowing) — the PR body, the changeset body and the claim agree on the one spelling. Nothing widens: no new export, no new accepted key, no new response shape, and the only type change (ArmedHook) is entry-unreachable. The accept sets of ApiTrigger.start() and AutomationEngine.registerFlow() narrow, which is breaking for any flow that binds the api trigger without a secret. yes would be false; a bare no would hide the break. Arm: RIGHT.
  • Level. Under the launch-window convention (scripts/check-changeset-no-major.mjs) major is refused and a breaking change ships as minor, with breaking-ness carried by the BREAKING banner and the ADR-0087 disposition. The changeset carries the banner, the FROM → TO 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 an adr-0087 marker comment reading not-required (no-migration-prescription) with its reasoning (nothing authorable changes spelling or type; the missing value is a shared secret no migration can synthesise; the other categories closed on facts). minor is the highest level the window allows, and patch would be wrong for a narrowing. Check Changeset and Lint & Repo Gates — the workflows that run the level axis and the ADR-0087 registration gate — are success on the head. Level: RIGHT.

③ Boundary flags

The report's open_questions is empty. Its five deviations, three out_of_scope_findings and the PR body's "Not done here" items, each answered or escalated:

  • a–d. Three git pushes against a budget of one; model-free commit trailers; a full turbo build for the dist-reading gates; a throwaway os validate fixture deleted before commit. Answered: process notes with no contract effect; the file list confirms no stray fixture path landed. Nothing to escalate.
  • e. Status-agnostic refusal — "the seat may want to confirm". Answered, confirmed: ①.7.
  • f. Out-of-scope, class b, security — a stored flow definition, start-node config.secret included, is served verbatim to any authenticated member by the automation domain's definition-read branch (source-read, not live-measured). ESCALATE. This is triage's measurement 2 answered YES, and triage wrote that any yes makes the card p0 and it regrades. It is outside this PR's file surface and does not make this diff wrong — refusing the anonymous door is strictly better than leaving it open regardless — but until it is closed the secret this PR mandates is readable by any member, so this PR's protection is against unauthenticated senders only. Needs its own security card (redaction or projection on the definition read; the metadata-plane read of the same definition still unmeasured) and a triage regrade.
  • g. Out-of-scope, class c — skills/objectstack-automation/SKILL.md:52 and :356 describe type: 'api' as invocable explicitly or bound as an inbound hook, and the secret as "strongly recommended" with unsigned posts accepted. Verified at the head. ESCALATE. Tier H (skills/**), outside the claim's file surface, so reporting rather than editing was the correct discipline. After this lands, an author following the published skill writes a flow the runtime refuses at publish and at boot; needs a Tier H closeout card.
  • h. Out-of-scope, class c — os validate passes a stack with a secretless api flow that the engine refuses (dev-measured). ESCALATE to the spec/lint lane: a defineStack refusal beside the trigger-capability refusal (keyed on resolveFlowTriggerKind), or a validate-flow-trigger-readiness rule. [finding] os validate runs only the stack schema parse, so a config that exports a plain object (no defineStack() call) skips every defineStack cross-field refusal and passes #20367 / PR feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build #20460 does not cover it. Same family as g — one closeout card can carry both authoring-time surfaces.
  • i. hookId fallback kept. Answered: ①.8.
  • j. Rule kept in two places. Answered, agreed: the two packages have no dependency on each other, the trigger registers at kernel:ready after the flow pull, and both copies read the one binding config (①.6). The only single home that would also reach os validate is spec — item h.
  • k. No scripts/adr-anchors/ entry for ADR-0041. ESCALATE as a small follow-up, non-blocking: the ADR id is left in the code at both load-bearing spots (Prime Directive [WIP] Add Chinese version of the documentation #13's first half); anchoring them is the second half and sits outside this card's file surface.
  • l. Studio flow designer (objectui) authoring of config.secret — not measured, sibling not checked out. ESCALATE as an open item for the seat: if the designer cannot author secret, every designer-authored api flow is now refused. Not measurable from this repo; non-blocking here.
  • m. Two content/docs observations (flows.mdx organisation-inheritance sentence; webhooks.mdx §16 listing inbound as a non-goal with no runtime), carrier none. Answered: docs-accuracy drifts predating this card; a docs-audit follow-up, not a condition on this PR.
  • n. Serial constraints. The claim read main at 03b19d9, the dev verified premises at 288611e (the merge-base), and main is now ba5927f; the PR is mergeable: true and the net diff against current main is the same nine files. No conflict and no intervening change to the touched files.

Disclosure: this is a security card; the defect and the fix are described here abstractly and no request recipe is given.

Implemented-by: claude/issue-20529-api-trigger-requires-secret
Reviewed-by: session_017B6YKCGu8CTY2KBWgwaHAs

VERDICT: PASS

Rendered by an isolated contract-review subagent and adopted by the domain:services seat (#6021, session_017B6YKCGu8CTY2KBWgwaHAs) at 2026-09-29T02:34Z, after a transcript check: 111 harness model stamps, all at CONTRACT_REVIEW_TIER, zero fallbacks; 38 Bash calls and 1 Write (this record, to its own scratch path); zero GitHub writes. The seat's disposition of the four ESCALATE flags:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 02:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 487a784 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20529-api-trigger-requires-secret branch September 29, 2026 02:53
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… 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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants