Skip to content

feat(lint): os validate refuses an api flow with no per-flow secret - #20593

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20553-validate-api-flow-secret
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20553-validate-api-flow-secret

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 #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 Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #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 #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 #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 [security] a flow's inbound-hook secret (config.secret on the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552's round-trip pins once [security] a flow's inbound-hook secret (config.secret on the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #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 fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #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.

Acceptance notes


Generated by Claude Code

…no per-flow secret

An `api`-bound flow (the engine's deriveTriggerBinding: array-form record
pre-check, then resolveFlowTriggerKind === 'api') whose start node carries
no string config.secret non-empty after trim is now an `error`,
flow-api-trigger-secret-missing, in the file's never-fire family. The
automation engine refuses the same flow at registration (ADR-0041), so
`os validate` no longer passes a flow no runtime will register.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
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

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 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 — 4 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 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 39aca93a6b12167e5b14402a0d4c806cc22efe5d — the merge of head afa9e266fd9729c6a12cd3bea1a348da10379ef2 into base 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f, 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 39aca93a6b12167e5b14402a0d4c806cc22efe5d && git checkout 39aca93a6b12167e5b14402a0d4c806cc22efe5d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f afa9e266fd9729c6a12cd3bea1a348da10379ef2 && git checkout -B drift-repro 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f && git merge --no-ff afa9e266fd9729c6a12cd3bea1a348da10379ef2

node scripts/docs-audit/affected-docs.mjs --json 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f

⚠️ 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 0f6dcac5e99d0c6211f0d8a0e150a112d78a776f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ecret rule

The changeset's Clause-② line now matches the PR body byte for byte:
yes (narrowing), because os validate / os build / os lint and the
runtime metadata publish gate newly refuse a secretless api-bound flow
while the new exported rule id widens @objectstack/lint. It gains a
BREAKING banner in the launch-window shape (minor, one-line fix) and
the ADR-0087 disposition not-required (no-migration-prescription): the
missing value is a shared secret no migration can supply.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Read-only, from git objects at refs/os-seat2/pr20593 (net diff against origin/main, merge-base f11b5f20a2), the card's body and every comment (triage 5883352078, claim 5883772263, os-dev-report 5884527630), merged PR #20551's diff and changeset, and this PR's body, comments, file list and check-runs. The head is the two commits 29caa84eb3 (implementation) and d1f76819c4 (changeset-only). Local checkout's refs/os-seat2/pr20593 and the API's head.sha agree. Adversarial to the dispatching seat's own conclusions by design; every judgment below is from the code, not the dev's prose.

① Derived judgments

Accept-set change — the rule refuses exactly the engine's registration refusal, no more and no less: RIGHT.

  • Binding. The engine's deriveTriggerBinding (packages/services/service-automation/src/engine.ts) is two steps: an array-form pre-check (Array.isArray(config.triggerType) and some element is a string that startsWith('record-')) routing to the record-change trigger, then resolveFlowTriggerKind(flow). validateApiTriggerSecret (engine.ts :9393) returns early unless resolved.triggerType === 'api'. The rule's isArrayRecordTriggered is that pre-check predicate term for term, and bindsApiTrigger = !isArrayRecordTriggered && triggerKind === 'api' where triggerKind = resolveFlowTriggerKind(flow) — the same @objectstack/spec export (packages/spec/src/automation/flow-trigger-kind.ts), whose order is record-token, timeRelative object, config.schedule or type: 'schedule', then type: 'api' or triggerType: 'api'. So every precedence shape resolves identically on both sides because one resolver decides both: type: 'api' plus a record-* token binds record-change; plus a timeRelative object binds time-relative; plus config.schedule binds schedule; type: 'schedule' beside triggerType: 'api' binds schedule; an array record token takes the pre-check; a scalar timeRelative: 'daily' is not an object and falls through to api on both sides. Neither side reads status (the engine's check runs before registerFlow records the status bit). A start-less flow reads config as {} on both sides (flow.nodes.find(type === 'start') in the engine and the resolver, findIndex in the rule — all the first start node).
  • The parsed-versus-raw seam does not open a gap: the engine judges FlowParsed, the rule judges the authored object, but the start node's config is z.record(z.string(), z.unknown()).optional() (flow.zod.ts), so parse preserves secret and triggerType as authored.
  • Secret. Engine: typeof config.secret === 'string' && config.secret.trim() !== ''. Trigger (packages/triggers/trigger-api/src/api-trigger.ts start()): typeof cfg.secret === 'string' && cfg.secret.trim() ? cfg.secret.trim() : undefined, then if (!secret) throw. Rule (describeUnusableSecret): string and non-empty after trim() is usable; blank, absent, or any non-string is not. The three are equal on every JavaScript value.
  • Premise: PR fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551's merge commit 487a7846df is an ancestor of the merge-base, so the refusal this rule mirrors is on main at the base.

Triage's "no second rule: read the engine's predicate, or state in the rule why it cannot" — the permitted branch, stated truly: RIGHT. validateApiTriggerSecret is a private method of a runtime package; the trigger's copy is inline in start(); packages/lint/package.json depends on @objectstack/spec, @objectstack/formula and @objectstack/sdui-parser only, never on a runtime; and @objectstack/spec exports no secret predicate — its only secret hits under automation/ are the outbound-webhook secret / io-node signingSecret fields, and the only exported functions named for secrets are datasource-credential-redaction paths. The docblock on FLOW_API_TRIGGER_SECRET_MISSING names both runtime copies and each of these reasons. A shared spec predicate would be its own spec-lane change editing both runtime copies (service-automation/src/** is held by in-flight #20552), which the docblock also says.

Where the refusal surfaces: RIGHT, and no stored row is re-judged. The validateFlowTriggerReadiness entry in packages/lint/src/authoring-rules.ts is commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow'] — unchanged by this diff, so the new id rides the family's existing wiring into os validate / os build / os lint and the publish gate. assertRuntimeAuthoringRules (packages/metadata-protocol/src/runtime-authoring-gate.ts) returns { error: null } unless args.state === 'active', then calls runRuntimeAuthoringRules({ type, item: body, context: { objects, permissions, books, datasets } }); that function (packages/lint/src/runtime-gate.ts) builds a baseline and a candidate snapshot in which the written flow is the sole member of flows and subtracts baseline findings, so only the item being written is judged. The /automation create, update and clone doors call registerFlow directly and never traverse this gate; boot-time loads of stored rows are refused by the engine (PR #20551), not by this rule. The changeset's "the gate judges only the item being written, so no stored row is re-judged here" is what the code does.

Message and hint: RIGHT. packages/triggers/trigger-api/src/plugin.ts:89 reads c.req.header('x-objectstack-signature'); verifySignature computes 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex') and compares constant-time — the hint's "sha256= and the hex HMAC-SHA256 of the raw body" is that code. describeUnusableSecret renders only null, an array or a plus typeof — never the value — and the test asserts not.toContain('12345') / not.toContain('s3cret') for every bad value. The message and hint carry no tracker number (ADR-0041 only), as runtime strings must. The binds clause names type: 'api' and/or start-node triggerType: 'api', the same two the engine's message names.

packages/lint/src/authoring-rules.ts, the one file outside the claim's listed surface: comment-only, and the count it corrects is correct. Every added or removed line in that hunk begins with // (checked mechanically over the diff). Before this diff exactly four rule ids in validate-flow-trigger-readiness.ts push severity: 'error' (FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE); with FLOW_API_TRIGGER_SECRET_MISSING it is five. packages/lint/src/index.ts adds the one barrel line rule-id-barrel-exports.test.ts requires — inside the claim's "whatever the gates ask for a new rule id".

Public-surface change: one new export, FLOW_API_TRIGGER_SECRET_MISSING, on @objectstack/lint's published index. Nothing removed or renamed; no packages/spec edit; no other package's src/** moves.

Pins: RIGHT, and they hold the substance. The new block asserts the rule id, severity: 'error', where and path (flows[0].nodes[0].config.secret; flows[0].nodes for the start-less flow) for a secretless type: 'api' flow with toEqual([id]) (exhaustive — nothing else may fire); a signed control passes; autolaunched passes and the same flow flipped to api fails; triggerType: 'api' on autolaunched / screen / record_change fails and passes with a secret; blank (' ', '', tab-newline), number, boolean, null, array and object secrets fail without echoing the value and a padded real secret passes; obsolete and draft are judged; the five precedence shapes stay silent and each is paired with the shape that fires; the id slug is pinned; the severity map's provoke table gains the id at error; the clean-stack floor gains a signed api flow. Removing the finding turns every toEqual([id]) assertion and the provoke row red; swapping the binding for the type OR triggerType disjunction fires on the api + config.schedule row. That is consistent with the dev's 8 red / 73 green and 1 red / 80 green readings, which are the dev's — nothing was re-run here.

Docs — the drift bot's content/docs/deployment/validating-metadata.mdx: not a defect this PR owes. The page names AUTHORING_RULES as the one table and carries a family-level row ("Flow trigger readiness — a flow that looks armed and never launches" with all four doors ticked, flow on the runtime column); it enumerates no rule id of this file (zero hits for any flow-… id on the page), and the four-door claim is exactly what CLI_AND_RUNTIME plus runtimeTypes: ['flow'] still declares. No doc, skill or reference at the head lists this file's rule ids (zero hits outside packages/lint). Acceptance note only.

The dev's out-of-scope finding: confirmed, and correctly outside. engine.ts :9397–:9404 throws one message, "declares no config.secret", for the blank and non-string cases too — the verdict is right, the wording is loose. packages/services/** is barred by the claim, and #20552 (p0, in flight, domain:services, claim 5883417172) holds service-automation/src/**. An acceptance note is the right disposition under Prime Directive 10; #20552 is the file-holder, not a scope owner, so a one-line card after it lands is the seat's option. Not this diff's defect.

Gate coverage on this head, read ONCE (not polled, not awaited). Concluded success: Check Changeset (its steps "Require an ADR-0087 disposition on a declared-breaking changeset" and "Guard against accidental major bumps (launch window)" both success), Governed Surface Queue Guard, Check PR Size, Flag docs affected by code changes, Check Documentation Links, the four PM guards (part-of, single-writer path, card claims this branch, no other PR claims the issue), Auto Label. Skipped by path filter: Build Docs, Console Pin Gate, Packed-tarball smoke. NOT concluded at the read: Lint and Repo Gates (queued); Type Check source gates / debt ledger / workspace / consumer gates (in_progress); Test Core 1–6 (in_progress); Dogfood Regression Gate 1–3 and Dogfood Verify CLI (in_progress); Build Core (in_progress); Temporal Conformance (in_progress). No run on this head had failed. Six of the seven required contexts were unconcluded; this record does not presume them green — the landing step reads them itself. The contract verdict below rests on ①–③ and on the concluded changeset gates; the lint, typecheck, test and build families are answered only by those runs when they conclude. (Runs on 29caa84eb3 were cancelled by the push and are not this head's.)

② Semver level

  • What the diff publishes. @objectstack/lint (published) gains one exported symbol and one gating error that narrows the accept set at os validate / os build / os lint and at the runtime publish gate for state: 'active' flow writes. No other published package moves.
  • Clause-②: yes (narrowing — …): RIGHT, judged independently. Under scripts/pm/clause2-line.mjs, yes (narrowing) is "a diff that widens one surface and narrows another; both facts are true and both are read". The widening is real by the WHICH LEVEL prose ("a new exported symbol on an index … takes at least minor"); the narrowing is real (a stack that validated exits non-zero). The dev's original yes (accept/reject: …) reads arm: null under readArmToken (the parenthetical opens with a non-arm word), so it declared no narrowing and would have shipped a breaking narrowing with no breaking signal at either changeset gate — the shape the arm exists to remove. Option B was right; option A's "one declaration, in the entry that made it" fails because fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551's changeset bumps trigger-api and service-automation, and @objectstack/lint's own CHANGELOG is what a consumer whose CI os validate newly reds will grep. The PR body's line 2 and the changeset's Clause-② line are byte-identical (compared, em dash included).
  • @objectstack/lint: minor with the BREAKING banner: RIGHT. The narrowing is breaking; the launch-window convention (check-changeset-no-major.mjs header: a breaking change ships as minor, and the carriers are the **BREAKING** banner plus the ADR-0087 disposition) puts the signal in the body, not the level. The banner opens **BREAKING**, names both doors, the before and after, and a one-line fix — the same shape as the precedent .changeset/17493-predicate-slot-blank-refused.md. check-adr-0087-registration.mjs reads both **BREAKING and the narrowing arm as breaking signals, and the job passed on this head.
  • ADR-0087 marker not-required (no-migration-prescription): RIGHT disposition, exactly one marker. Nothing authorable changes spelling or type and packages/spec is untouched; a missing shared secret is not a value migrate meta can synthesize, and the alternative rewrite (to autolaunched) depends on an intent no migration can read; registered would need an entry that rewrites something and none exists; unpublished, already-registered and runtime-interface-only are closed on facts. The body carries no arrow-form or table-form prescription for the gate's hasMigrationPrescription to refuse (zero hits for arrows, table rows or migration headings). It is the disposition fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551's changeset carries for the same shape, accepted at its merge, and this PR's marker says why the stored-row path belongs to that entry.
  • The changeset carries no model identifier; both commits end with the model-free Claude-Session: / Co-authored-by: Claude pair.

③ Boundary flags

Every deviation and open question in os-dev-report 5884527630, and the PR body's acceptance notes:

  1. authoring-rules.ts outside the claimed surface — ANSWERED in ①: comment-only, count correct. Accepted.
  2. Binding derivation versus the claim's :507 / :618 — ANSWERED in ①: :618 is the "routes somewhere" disjunction and would refuse five shapes the engine registers; the engine's two-step derivation is the right one, verified from the resolver's code. The claim's line was inexact and the dev's correction is right.
  3. The start-less api flow judged at flows[i].nodes — ANSWERED: both sides read config as {}; the engine refuses it for the same reason. Accepted.
  4. Probe stack under examples/app-showcase/.probe-20553/ — ANSWERED: the head's file list is five files, none under examples/. Accepted.
  5. Attribution trailer — ANSWERED: AGENTS.md's model-free pair is what the commits carry, and AGENTS.md wins. Accepted.
  6. open_questions — the Clause-② arm (A: keep yes; B: yes (narrowing) plus banner and marker) — ANSWERED in ②: B, which the seat took at d1f76819c4; right.
  7. Publish-gate reach measured at runRuntimeAuthoringRules rather than through a live saveMetaItem write — ANSWERED: the wiring from assertRuntimeAuthoringRules to it is unconditional for state: 'active' and read above. Accepted.
  8. The engine's "declares no config.secret" wording for a non-string secret — ANSWERED in ①: confirmed, correctly outside, acceptance note. Not a FAIL reason.
  9. The pre-existing test title ("flags schedule and api flows for missing status too" builds only a schedule flow) — ANSWERED: pre-existing, harmless, no carrier owed.
  10. Docs drift on validating-metadata.mdx — ANSWERED in ①: family-level row, no enumeration; nothing owed.
  11. "Not measured: whether the Studio flow designer lets an author set config.secret on an api start node" — ESCALATED, non-blocking: the sibling repo is outside this review's inputs, so it is not settled here. The publish gate now answers such a write with a located 422 naming the fix, which is the loud path; whether the designer offers the field is an objectui-lane question the seat may file there.

Implemented-by: claude/issue-20553-validate-api-flow-secret
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 06:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36532203829 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (2/6) — 失败步骤: Run this shard's tests

    @objectstack/metadata-protocol:test:  FAIL  src/protocol.metadata-redaction.test.ts > #20552 — the protocol serves the projection and executes the stored body > GET → edit → PUT keeps the stored secre
      ↳ 失败原因: @objectstack/metadata-protocol:test: Error: flow/inbound_hook failed author-time validation: 1 issue — flows[0].nodes[0].config.secret [flow-api-trigger-secret-missing]
    @objectstack/metadata-protocol:test:  FAIL  src/protocol.metadata-redaction.test.ts > #20552 — first save of a registry-only (code-authored) flow keeps its secret > the served body saved straight back
      ↳ 失败原因: @objectstack/metadata-protocol:test: Error: flow/inbound_hook failed author-time validation: 1 issue — flows[0].nodes[0].config.secret [flow-api-trigger-secret-missing]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/protocol.metadata-redaction.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 8 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

…nly, as its own rule

The api-trigger secret judgement moves out of validateFlowTriggerReadiness
into its own exported rule, validateFlowApiTriggerSecret, on its own
registry entry: gating, all three commands, surfaces CLI_ONLY with a
surfaceReason. The runtime publish gate judges a /meta save before
saveMetaItem restores the inbound-hook secret the flow read path
withholds, so a signed flow's GET, edit, PUT round trip would reach the
rule secretless and be refused. Until the gate judges the carried-forward
body, the id stays off that surface; os validate / os build / os lint keep
the refusal. Pins cover both sides of the wall, with a positive control at
the gate.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Sep 29, 2026
…mmands

The Clause-② line matches the PR body again (os validate / os build /
os lint only). The publish-gate sentences go, replaced by one saying the
gate is deliberately not covered yet. The Why paragraph and the ADR-0087
reason name 17.5.0, the release whose published changelog carries the
engine's registration refusal, instead of "the same release".

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
The CLI-only validateFlowApiTriggerSecret entry takes the registry from 46
to 47 rules for every command, and check:docs-transcript-drift holds each
pasted `Running author-time rules (N)...` line to authoringRulesFor(cmd).
Exactly the four quoted lines change.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Round B, judged on this head alone. Read-only, from git objects at refs/os-seat2/pr20593 (net diff against origin/main, merge-base c96beb2707 = #20552's landing; the local ref and the API's head.sha agree), card #20553's body and every comment (triage 5883352078, claim 5883772263, os-dev-report 5884527630, the earlier ACCEPT 5884818533 on the OLD head, the fork ruling 5885679535), card #20611's body, and this PR's rewritten body, its comments (the old-head record 5884781463, the dequeue 5885309371), its file list and the check-runs on this head. The earlier record on d1f76819c4 judged a CLI_AND_RUNTIME wiring that no longer exists; nothing below is inherited from it. Adversarial to the dispatching seat by design: every judgment is from the code, not the dev's prose.

① Derived judgments

Surface: the rule is CLI-only, structurally, and the /meta round trip that dequeued the PR cannot be refused by it again: RIGHT. packages/lint/src/runtime-gate.ts runtimeAuthoringRulesFor(type) filters AUTHORING_RULES to the entries whose surfaces includes 'runtime-publish' and whose runtimeTypes includes the written type, and runRuntimeAuthoringRules runs nothing else. The new registry entry validateFlowApiTriggerSecret in authoring-rules.ts is surfaces: CLI_ONLY (['cli']), no runtimeTypes, so it is filtered out before any snapshot is built. Both protocol call sites, saveMetaItem (protocol.ts:16352) and publishMetaItem (:17747), go through assertRuntimeAuthoringRules to evaluateRuntimeAuthoringGate (:5148) to that one function; the protocol names no rule of its own. The id flow-api-trigger-secret-missing is pushed at exactly one site in the head's rule file (inside validateFlowApiTriggerSecret), and the only edit to validateFlowTriggerReadiness's body is the extraction of the array-form record predicate into isArrayRecordTriggerType, term for term the inline expression it replaces, so the family member that still crosses the wall judges the round-trip pin's shape (type: 'api', start triggerType: 'api', hookId, active) exactly as it did on main, where those pins pass. That is impossibility from the filter, not absence of exercise; the test file pins both sides (rulesRun holds validateFlowTriggerReadiness and not validateFlowApiTriggerSecret, no such id in errors or advisories, with a positive control that the same door still refuses a dead record_change flow with flow-trigger-unroutable).

CLI reach is real on all three declared commands: RIGHT. commands: ALL puts the entry in authoringRulesFor('validate'|'build'|'lint'). validate.ts runs runAuthoringRules('validate'), splitBySeverity, and this.exit(1) on errors; compile.ts (build.ts is its alias) does the same for 'build'; lint.ts folds runAuthoringRules('lint') into issues, counts errors into failing, and process.exit(1). The test's it.each(AUTHORING_COMMANDS) pin drives the table itself for each command.

Surface bookkeeping: nothing stale. Registry at base: 46 entries, 43 on ALL and 3 on ['validate', 'build']; at head 47, 44 and 3. So authoringRulesFor answers validate 47, build 47, lint 44. The CLI prints authoringRulesFor(command).length (validate.ts:416, compile.ts:458), so 47 is what os validate and os build print at this head. The four transcripts moved are the only fences in content/docs/** that quote the count, and each is declared text transcript=os-validate or text transcript=os-build (cli.mdx sits under os compile, which the drift script maps to build); no os-lint transcript quotes a count anywhere; docs/audits/** quotes 41 and is deliberately out of the drift gate's population as a dated record; no prose quotes 46/47/24/25 rules in content/docs, skills/ or READMEs. The wiring guard (authoring-rule-wiring.test.ts) ratchets no CLI-only list by name; its family-split pin is scoped by name to the views[] visibility family; the new entry satisfies its universal rules (gating on all three commands, a surfaceReason over 40 characters, no runtimeTypes off the runtime door). rule-id-barrel-exports.test.ts requires the id constant on a published barrel; index.ts adds it and the function. runtime.ts enumerates no rule names. No generated projection of the registry is committed (the drift check reads the built registry; validating-metadata.mdx's four-door table is hand-written, see the acceptance note below).

The split is the registry's own precedent, not the #7214 silent split: RIGHT. validateSecurityRoleWord was split out of validateSecurityPosture, same source file, so it could stay behind the wall WHOLE rather than cross for a subset (its entry comment says so), which is exactly this diff's shape: one rule id, one function, one entry, one side of the wall, declared by a surfaceReason and pinned on both sides.

surfaceReason: true to the code, and #20611 is an honest carrier. saveMetaItem runs assertRuntimeAuthoringRules on request.item as submitted (:16352, placed right after the schema check) and only later assigns request.item = await this.carryForwardRedactedCredentials(...) (:16588, commented as AFTER every gate, immediately before the put). The string's claim that the gate judges a /meta save BEFORE the stored inbound-hook secret is restored is that order. The string carries no tracker number (the check:doc-authoring discipline for author-visible prose; it says "the seam follow-up named in the comment above"), and the comment above it names #20611. #20611's body names the same seam by the same line numbers (:16352 gate, :16588 carry-forward), the same two failing pins, the same ablation, and carries the packages/lint half explicitly: return this id to CLI_AND_RUNTIME and remove its surfaceReason once the gate judges the carried-forward body. That is the reverse of this diff's edit, so the hold-off is recoverable by construction.

Refusal truth: the CLI refuses exactly the flow registration refuses, no more and no less: RIGHT. Engine (service-automation/src/engine.ts): registerFlow calls validateApiTriggerSecret(name, parsed) unconditionally, after FlowSchema.parse; that method returns unless deriveTriggerBinding answers api, where deriveTriggerBinding is the array-form pre-check (Array.isArray(config.triggerType) with some string element startsWith('record-'), routed to record-change) and otherwise resolveFlowTriggerKind(flow); it then requires typeof config.secret === 'string' && config.secret.trim() !== '' on the FIRST start node's config ?? {}. Rule: bindsApiTrigger = !isArrayRecordTriggerType(config) && resolveFlowTriggerKind(flow) === 'api' with the identical pre-check predicate and the identical spec export (packages/spec/src/automation/flow-trigger-kind.ts: record token, then timeRelative object, then config.schedule or type: 'schedule', then type: 'api' or triggerType: 'api'); describeUnusableSecret accepts a string non-empty after trim() and refuses absent, blank and every non-string, on the first start node (findIndex on type === 'start'), config ?? {}. The trigger's own copy (trigger-api/src/api-trigger.ts start()) reads typeof cfg.secret === 'string' && cfg.secret.trim(). The three are equal on every JavaScript value. The parsed-versus-authored seam opens no gap: FlowSchema makes type a required enum with no default and the start node's config an open record, and no ADR-0087 conversion rewrites a flow's triggerType or secret (the only 'api' conversions are action and datasource entries). status is read by neither side. A start-less flow reads config as {} on both sides. I find no flow the CLI refuses that registration accepts, and none registration refuses that the CLI passes: the five precedence shapes bind another trigger on both sides because one resolver decides both; a scalar timeRelative is not an object on either side and falls through to api; the array form is caught by the same pre-check on both. The test's pins hold that line: toEqual([id]) on the positive shapes (exhaustive), silent on the five precedence shapes each paired with the shape that fires, blank and non-string values refused without the value echoed, a padded secret accepted, obsolete and draft judged, the start-less flow located at flows[i].nodes, the slug pinned, the provoke severity row and the clean-stack floor extended.

Message and hint: RIGHT. The message names the same two declarations the engine's message names, renders only a bad value's type, and cites ADR-0041 only; the hint names x-objectstack-signature with sha256= plus hex HMAC-SHA256 of the raw body, which is verifySignature's computation, and prescribes type: 'autolaunched' for the explicit-only flow, the same fix PR #20551's changeset prescribes.

Public surface: two new exports on @objectstack/lint's index, validateFlowApiTriggerSecret and FLOW_API_TRIGGER_SECRET_MISSING. Nothing removed or renamed; packages/spec untouched; no other package's src/** moves. packages/lint/package.json depends on @objectstack/spec, @objectstack/formula, @objectstack/sdui-parser and tooling only, so the docblock's dependency-direction reason for carrying the predicate is true.

Scope: exactly the nine files, and nothing generated owes a move. Changeset, four transcripts, authoring-rules.ts (+37/-2: the entry, its comment, the family comment's count), index.ts (+2), the rule file, its test. No packages/*/CHANGELOG.md, no content/docs/releases/, no packages/spec artifact. origin/main has advanced seven commits past the merge-base; none touches any of the nine files (aa23e2c8fd, the lint-src citation sweep, deliberately left authoring-rules.ts at its base blob for this PR), and the PR reads mergeable_state: clean.

Gate coverage on this head, read ONCE (not polled, not awaited). 42 check-runs, every one completed: 38 success, 4 skipped, 0 failed, 0 unconcluded. Success: Lint and Repo Gates (pnpm lint, check:doc-authoring, check:issue-citations, check:docs-transcript-drift live there), TypeScript Type Check with source gates, debt ledger, workspace and consumer gates, Build Core, Build Docs (the four content/docs edits compile), Test Core 1 to 6 and the fan-in, Dogfood Regression Gate 1 to 3 and Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Check Changeset twice (runs 36538166187 and 36543723800, the job that runs check-adr-0087-registration and check-changeset-no-major), Governed Surface Queue Guard, Check Documentation Links, Flag docs affected, Auto Label, and the four PM guards twice. Skipped: Console Pin Gate and Packed-tarball smoke (path filter and opt-in), and the second run's Auto Label and Check PR Size (re-run duplicates of a green first run). The PR-side runs merged the head into base 0f6dcac5e9 (07:41Z), before aa23e2c8fd landed (08:10Z); no file overlaps, and the merge-queue run re-tests the merged tree, which is the landing step's business, not this record's.

Acceptance notes (not FAIL reasons).

② Semver level

  • What the diff publishes. @objectstack/lint gains two exported symbols and one gating error that narrows the accept set of os validate, os build and os lint. No other published package moves; the runtime publish gate's accept set is unchanged from main.
  • Clause-②: yes (narrowing ...) naming only the CLI doors: RIGHT, judged independently. The widening is real (new exports on an index take at least minor); the narrowing is real (a stack that validated exits non-zero) and is confined to the three commands, which is what the parenthetical now says. The runtime door is correctly absent from the arm's list, since main already passes a secretless flow there. The PR body's line and the changeset's line are byte-identical (196 characters, em dash included).
  • @objectstack/lint: minor with the BREAKING banner: RIGHT level, right arm. Under the launch-window convention check-changeset-no-major refuses major; breaking-ness is carried by the **BREAKING** banner and the ADR-0087 disposition, both present. The banner names the three doors, the before and after, and a one-line fix, the same shape as the #17493 precedent.
  • ADR-0087 marker not-required (no-migration-prescription): RIGHT disposition, exactly one marker. Nothing authorable changes spelling or type; packages/spec is untouched; a missing shared secret is not a value migrate meta can synthesize. The body carries no FROM/TO label or migration heading for findMigrationPrescription to refuse. It is the same disposition PR fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551's changeset shipped for the runtime half, verified in packages/services/service-automation/CHANGELOG.md under ## 17.5.0 (entry 487a784, trigger-api arms a flow's inbound hook without a secret and accepts unsigned posts; ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification #20529, with the same marker).
  • Ruling B's wording corrections landed. The changeset's only publish-gate sentence states that the runtime gate is deliberately NOT covered yet (Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #20611), which is true. "since 17.5.0" is true (above). No "same release" wording remains; the old "gate judges only the item being written" sentence is gone. Tracker numbers in a changeset are permitted (not an author-visible runtime string).
  • Commit trailers on all three round-B commits are the model-free Claude-Session: / Co-authored-by: Claude pair.

③ Boundary flags

Every deviation and open question in os-dev-report 5884527630, the ruling's stipulations, and the PR body's acceptance notes for this head:

  1. authoring-rules.ts outside the claim's listed surface: now a real entry, admitted by ruling 5885679535 ("the registry carries a declared surfaceReason naming Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #20611"). ANSWERED in ①: entry shape, reason and pointer verified.
  2. Binding derivation versus the claim's :507 / :618 reading: ANSWERED in ①, from the resolver's and the engine's code; the two-step derivation is the engine's own.
  3. The start-less api flow judged at flows[i].nodes: ANSWERED, both sides read config as {}.
  4. Probe stack under examples/: ANSWERED, not in the nine-file list.
  5. Attribution trailer: ANSWERED, the model-free pair is what the head's commits carry.
  6. open_questions, the Clause-② arm: ANSWERED in ②; B was right, and ruling B's narrowing of the parenthetical to the CLI doors is right on this head.
  7. Ruling B's four stipulations (CLI-only, surfaceReason naming Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #20611, arm naming only CLI doors, changeset wording): each ANSWERED above; all four hold.
  8. Publish gate deliberately not covered (Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #20611): ANSWERED in ①, the seam is real, the carrier names it, the hold-off is declared and reversible.
  9. The dequeue's two pins: ANSWERED in ①, structurally impossible on this head.
  10. Engine wording for a non-string secret: ANSWERED as outside; acceptance note.
  11. Pre-existing test title ("flags schedule and api flows" builds only a schedule flow): ANSWERED, pre-existing, harmless.
  12. Docs drift on validating-metadata.mdx: ANSWERED as an acceptance note (①), with the row to add named.
  13. "Not measured: whether the Studio designer can author config.secret": ANSWERED by the seat's own ACCEPT 5884818533, which measured it and filed objectui#11054; outside this repo and this diff.

Implemented-by: claude/issue-20553-validate-api-flow-secret
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit e651556 Sep 29, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20553-validate-api-flow-secret branch September 29, 2026 09:18
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… to the commits that decided them (objectstack-ai#20612)

Part of objectstack-ai#20597
Clause-②: no

The `packages/lint` stage of the dead-citation sweep: the `domain:spec`
lane's only package (census `5884031174` on objectstack-ai#20556, claim `5885046469`).
Every comment or docblock line in 22 of the 23 claimed
`packages/lint/src/` files that cited a tracker number answering 404 now
cites, in ruling C+D's form C, the commit in this repository's history
that decided what the line describes, and says in its own words what was
decided. Comments only: 81 lines out, 81 in, across 22 files. No code
token, string literal, rule message, hint or rule id moves.

`authoring-rules.ts` (5 sites) is excluded and left at its base blob: PR
objectstack-ai#20593 (objectstack-ai#20553) edits it and was still open at the last read
(2026-09-29T07:34Z). So this PR says `Part of`: those 5 sites stay for a
follow-up once that PR lands, with their anchors already verified (see
Acceptance notes).

The census is the gate's own `node scripts/check-issue-citations.mjs
--census --json`, filtered to `packages/lint/`. Before: base
`7a1faf1a5d`, 2026-09-29T06:42:03Z to 06:45:24Z, board enumerated (185
pages, frontier objectstack-ai#20606). After: head `0c7b847f18`, 07:28:22Z to
07:31:52Z (185 pages, frontier objectstack-ai#20611).

## Measurement

| file (under `packages/lint/src/`) | dead before | after | numbers,
then anchor |
|---|---:|---:|---|
| `lint-flow-patterns.ts` | 9 | 0 | objectstack-ai#13681 ×9 to `8ed9c54b4` (objectstack-ai#14394
stays, 200) |
| `validate-hook-body-writes.ts` | 9 | 0 | objectstack-ai#8663 ×6 to `192213f66`;
objectstack-ai#13657 ×3 to `b003cf2e8` |
| `runtime-gate.ts` | 8 | 0 | objectstack-ai#10064 ×5 to `def0d3e63`; objectstack-ai#19370 ×2 to
`a227afa41` (objectstack-ai#19143 stays); objectstack-ai#9798 to `c7655d472` (objectstack-ai#9261 stays) |
| `validate-searchable-fields.ts` | 7 | 0 | objectstack-ai#8404 ×4 to `b849e6911`, the
`pre-objectstack-ai#8404` control at `:312` included; objectstack-ai#10001 ×3 to `f1b5ad39a` |
| `authoring-rules.ts` | 5 | **5** | excluded: PR objectstack-ai#20593 holds the file
|
| `validate-expressions.ts` | 5 | 0 | objectstack-ai#6290 ×5 to `e9b526597` (objectstack-ai#6584,
that commit's own PR, stays) |
| `validate-react-page-props.ts` | 5 | 0 | objectstack-ai#11284 ×4 to `5383fa670`;
objectstack-ai#8404 to `b849e6911` |
| `validate-sortable-fields.ts` | 5 | 0 | objectstack-ai#10001 ×4 to `f1b5ad39a`;
objectstack-ai#8404 to `b849e6911` |
| `validate-flow-node-writes.ts` | 4 | 0 | objectstack-ai#8663 ×4 to `192213f66` |
| `validate-page-field-bindings.ts` | 4 | 0 | objectstack-ai#6629 ×2 to `cd584d559`
(plus the slash-joined `:208`, see Deviations); objectstack-ai#8664 ×2 to `8798cd2a6`
|
| `validate-translation-references.ts` | 4 | 0 | objectstack-ai#14700 ×3 to
`de3c52beb` (objectstack-ai#14253 stays); objectstack-ai#6124 to `b3c1f3cd5` |
| `lint-liveness-properties.ts` | 3 | 0 | objectstack-ai#10262 ×3 to `2aca1bc4c` |
| `validate-action-body-writes.ts` | 3 | 0 | objectstack-ai#8663 ×3 to `192213f66` |
| `validate-security-posture.ts` | 3 | 0 | objectstack-ai#19370 ×3 to `a227afa41`
(objectstack-ai#8310 stays) |
| `flow-template-grammar.ts` | 2 | 0 | objectstack-ai#11060 ×2 to `815585513` |
| `data-model-rules.ts` | 1 | 0 | objectstack-ai#10064 to `def0d3e63` |
| `reference-integrity-suite.ts` | 1 | 0 | objectstack-ai#13653 to `36d287803` |
| `validate-component-types.ts` | 1 | 0 | objectstack-ai#12950 to `225e7690f` (objectstack-ai#12183
stays) |
| `validate-empty-combinators.ts` | 1 | 0 | objectstack-ai#6528 to `3510e4a25` (objectstack-ai#5659
stays) |
| `validate-list-view-field-refs.ts` | 1 | 0 | objectstack-ai#10001 to `f1b5ad39a` |
| `validate-readonly-action-writes.ts` | 1 | 0 | objectstack-ai#13653 to `36d287803` |
| `validate-readonly-flow-writes.ts` | 1 | 0 | objectstack-ai#13653 to `36d287803` |
| `validate-readonly-hook-writes.ts` | 1 | 0 | objectstack-ai#13653 to `36d287803` |
| **23 files** | **84** | **5** | 21 numbers; 19 removed from the 22
edited files, to 19 distinct shas |

Per-file counts at base equal the claim's (census at `f11b5f20a2`) in
all 23 files. A second instrument agrees site for site: every `#N` in
the 23 files, classified by the TypeScript parser as comment, string or
code, and each of 360 distinct numbers probed by REST `issues/N` without
following redirects. At base it found 1,323 sites (1,259 comment, 64
string, 0 code); 339 numbers answer 200 and 21 answer 404, the census's
21. Its dead comment sites are the census's 84 plus one slash-joined
`objectstack-ai#5775/objectstack-ai#6629` the grammar does not read, and it found one dead
**string**: `validate-react-page-props.ts:1198` (see Acceptance notes).
At head: 1,243 sites and 344 numbers, the same 339 answer 200, and 5
answer 404, all in `authoring-rules.ts` comments or that one string. Lit
controls objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200, and dead controls
objectstack-ai#16714, objectstack-ai#16715 and objectstack-ai#16697 answered 404, at every checkpoint (5 at base,
5 at head).

## Why each anchor decides its line

Each sha resolves uniquely, is an ancestor of `origin/main` and of the
base, has one parent, and names the number it replaces in its own
message (15 of 19) or its own diff (17 of 19); every one does at least
one. Each was read for the rule its line states.

- **objectstack-ai#13681 to `8ed9c54b4`**: lands the per-iteration containment rule
PAIR (`flow-loop-body-uncontained`, `flow-try-catch-without-catch`) and
its measured minimal `catch`; its diff wrote all nine lines, and its
changeset records the measurements the lines cite. objectstack-ai#14394 (the rule
card, 200) stays beside it.
- **objectstack-ai#8663 to `192213f66`**: "three write rules ask anchor provenance
before exempting a system column"; its body names objectstack-ai#8663 and its diff
wrote the `[objectstack-ai#8663]` lines in all three rule files.
- **objectstack-ai#13657 to `b003cf2e8`**: the post-hook half of the declared-field
door, one envelope on every driver; its diff wrote the three lines.
- **objectstack-ai#10064 to `def0d3e63`**: name-keys collection-resident publish-gate
finding paths; its body reads "maintainer ruling 2026-08-20: Option A"
for objectstack-ai#10064.
- **objectstack-ai#19370 to `a227afa41`**: `security-role-word` crosses to the runtime
publish gate, whole, per ruling batch objectstack-ai#203 item 3 letter B; it maps
`position` / `app` and writes the past-tense crossing lines.
- **objectstack-ai#9798 to `c7655d472`**: the change that carried objectstack-ai#9798 to done (its
body names it), restoring the sys_comment unscoped multi-delete refusal
that could not fire through the wired engine, the
declared-but-unenforced fail-open the line lists beside objectstack-ai#9261 and
ADR-0110 D3.
- **objectstack-ai#8404 to `b849e6911`**: warns when `searchableFields` declares an
unprovisioned injected anchor, adding the optional provenance index the
lines describe; the SORT twin line names it as the SEARCH wiring.
- **objectstack-ai#10001 to `f1b5ad39a`**: a standalone ViewItem record's nested
`config.sort` / `config.searchableFields` reach the runtime publish
gate, the RECORD rung.
- **objectstack-ai#6290 to `e9b526597`**: `current_user` joins `SCOPE_ROOTS`, the
field-level rejection becomes its own rule, and option-level
`visibleWhen` is walked for the first time. `:770` quotes `SCOPE_ROOTS`'
docblock in `packages/formula`; the quote now stops at "the last one
this list was missing", verbatim, with the commit outside the quotation.
- **objectstack-ai#11284 to `5383fa670`**: the ListView react-tier vocabulary
converges on the metadata-tier spelling, deprecate-first; its changeset
reads "(objectstack-ai#11284, maintainer ruling 2026-08-23)".
- **objectstack-ai#6629 to `cd584d559`**: drops the retired `displayField` /
`searchFields` from the record_picker entry and adds
`component-field-specs-liveness.test.ts`.
- **objectstack-ai#8664 to `8798cd2a6`**: names what actually guards the
`unprovisionedAnchors` wiring; its diff wrote both lines.
- **objectstack-ai#14700 to `de3c52beb`**: descends into `conditional` `then` /
`otherwise` when building the `_validations` universe; its diff wrote
all three lines.
- **objectstack-ai#6124 to `b3c1f3cd5`**: the squash commit of objectstack-ai#6124 itself, leg 1 of
the `_views` key ruling (the CLI i18n extractor keyed by the runtime
view identity).
- **objectstack-ai#10262 to `2aca1bc4c`**: adds the package-internal test seam for
`getNested`'s array fan-out; its diff wrote all three lines.
- **objectstack-ai#11060 to `815585513`**: its body records "Maintainer ruling on
objectstack-ai#11060 (2026-08-23): option A", the CEL-mirrored six with no second
semantics, which the lines quote.
- **objectstack-ai#13653 to `36d287803`**: gates a hook body's `ctx.api` write to a
readonly field, and shares `buildReadonlyIndex` from the flow rule, the
export `:118` describes.
- **objectstack-ai#12950 to `225e7690f`**: created `validate-component-types.ts`, the
author-time rejection for unknown component types in spec-reserved
namespaces (stage 5's anchor for the same number).
- **objectstack-ai#6528 to `3510e4a25`**: the squash commit of objectstack-ai#6528 itself, one
implementation of the filter identity reduction (maintainer ruling
2026-08-06, option 1). The line read `PR objectstack-ai#6528`; it now names the
commit.

Rung: no ADR, `docs/NORTH-STAR.md` or `scripts/adr-anchors/` file
records any of these 19 decisions (the one lint anchor file,
`data-model-rules.ts`, pins ADR-0120, which none of these lines cites),
so the commit rung is the right one, as in objectstack-ai#20234's stages.

## Mechanical proof

- **Token guard** (scratch `tokcmp.mjs`: TypeScript 6.0.3 leaf tokens,
JSDoc kinds excluded, controls mutate the head text in memory only). The
merge base `c96beb2707` against the head, 22 files, 54,508 base tokens
(the 22 files are byte-identical at `7a1faf1a5d` and at the merge base):
  - Real run: 0 files with a token change (exit 0).
  - Comment-insertion control (`runtime-gate.ts`): 0 (exit 0).
- Code-insertion positive control (`validate-hook-body-writes.ts`):
DIFFER at token 216 (exit 1).
- String positive control (a parser-located `StringLiteral` in
`validate-react-page-props.ts`): DIFFER at token 5 (exit 1).
- **Line balance**: every file is +N/−N (81/81 across 22 files), every
changed line is comment-shaped, and every line count is equal at base
and head.
- **Tracker numbers**: added-not-removed is empty in every file, and no
`PR #N` stands on an added line. Net-removed: 80 sites (the census's 79
in these files plus the slash-joined one), 19 numbers. The numbers kept
on added lines all answer 200: objectstack-ai#5659, objectstack-ai#5775, objectstack-ai#8310, objectstack-ai#8340, objectstack-ai#9261, objectstack-ai#9313,
objectstack-ai#12183, objectstack-ai#13390, objectstack-ai#14253, objectstack-ai#14394, objectstack-ai#19143, and objectstack-ai#6584 (a pull request, the
anchor commit's own PR).
- **Shas**: 19 distinct on added lines, 0 on removed lines. `rev-parse
--disambiguate` answers 1 object for each; `merge-base --is-ancestor`
exits 0 against `origin/main` and against the base; each is
single-parent; the repository is not shallow; the control leg
`e9584681a4` exits 0.
- **Literal readers**: every string or regex literal in the repository
that carries one of the 21 numbers (85 literals) was matched against the
23 files' text: no reader of any rewritten line. The lint tests that
read these sources as text stay green below. For example,
`validate-expressions.test.ts` strips comments before it matches, and
`validate-security-posture.runtime-surface.test.ts` collects the
`stack.X` reads inside `validateSecurityRoleWord`, which no added line
carries.

## Tests and gates (at head `0c7b847f18`)

- `pnpm exec turbo run build --concurrency=2 --filter=./packages/*
--filter=./packages/*/*` under `os-verify-lock`: Tasks 71 successful, 71
total, VERDICT command-exit 0.
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` under
the lock: Test Files 115 passed (115), Tests 5363 passed (5363); then
`pnpm --filter @objectstack/lint typecheck`: exit 0,
`check:test-typecheck` OK (2 files / 6 errors / 2 pinned signatures
held). VERDICT command-exit 0. The same two runs passed with the same
counts on the pre-merge head `2ce32f6b48`.
- Lint, a proven narrowing: `eslint --no-inline-config --format json`
over the 22 touched `.ts` files gives 22 files, 0 errors, 0 warnings.
`isPathIgnored` is false for all 22, read through eslint's API.
`eslint.config.mjs:327-328` says type-aware linting is never enabled, so
a comment edit cannot move an untouched file's verdict. The repo-wide
`pnpm lint` is CI's.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 54 families derived, all run, every one exit 0. `--ran`
reads "54 derived, 54 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero
(every line carries its exit code). Among them:
- `node scripts/check-issue-citations.mjs` (the live diff-scoped run)
judged 18 citations across 22 files: 17 answer as issues and 1 answers
as a pull request, the kept objectstack-ai#6584; `pnpm check:issue-citations`
(self-test, 114 cases in 8 batteries) passes.
- `pnpm check:doc-authoring`: the sibling prose-id baseline holds, 810
pinned sites across 230 files, no growth.
- `pnpm check:nul-bytes`: OK over 9,239 tracked text files; a
control-byte scan of the 23 changed files finds none.
- No generated page carries a lint docblock: no page under
`content/docs/references/` names any of the 19 numbers, and no generator
reads `packages/lint/src`, so nothing was regenerated.
- Changeset: `patch` for `@objectstack/lint`. `files[]` ships `dist`,
and the rewritten comments reach it: 12 of the 19 shas appear in the
built `dist` (for example `8ed9c54b4` and `def0d3e63` in `index.d.ts`,
`b849e6911` in `index.js` and `index.d.ts`); the positive control, the
unchanged sentence "the near-miss shape: a `try_catch` that declares no
`catch`" of an exported docblock, is in `index.d.ts`. Hence patch, not
`skip-changeset`.
- Merge probe: a no-driver `merge-tree` of the head onto `origin/main`
`0f6dcac5e9`, from a bare shared clone with no `merge.*` config, exits
0. The two commits `main` gained after the merge touch none of the 23
files.
- No ablation or reverse verification: the change is comment-only, so
there is no behaviour to invert.

## Hypotheses (measured first)

1. **Holds.** 84 dead sites, 21 numbers, 23 files at the tip
`7a1faf1a5d`, equal per file to the claim.
2. **Holds.** Only comment and docblock lines moved. The one dead number
inside a string (`validate-react-page-props.ts:1198`, a finding
`message`) stays byte-identical; no test or script reads a rewritten
line by literal.
3. **Holds, and conditions the card.** PR objectstack-ai#20593 was still open at
07:34Z, so `authoring-rules.ts` stays at its base blob. At that read,
the 9 open PRs' full file lists and the newest `Claim:` on all 11
`pm:dispatched` cards name none of the other 23 paths.
4. **Holds.** `validate-searchable-fields.ts:312` `pre-objectstack-ai#8404` is listed
dead before and is gone after.
5. **Holds, with nothing to regenerate.** No lint docblock projects into
a generated page; no release page is touched.

## Deviations

- Two changed lines beyond the census's sites.
`validate-page-field-bindings.ts:208` carried `objectstack-ai#5775/objectstack-ai#6629`, a
slash-joined dead number the citation grammar does not read; it now
reads "the same objectstack-ai#5775 residue class (commit cd584d5)", stage 5's
precedent for the slash-joined `objectstack-ai#9972`. `runtime-gate.ts:780` is the
other half of the rewritten `:779` sentence and held no number.
- `origin/main` was merged once (`0c7b847f18`, merging `c96beb2707`):
the first derivation read STALE TREE because
`scripts/sdui-manifest.record.json` changed on `main`. The merge was
clean, no driver-routed path and no lockfile change, and it touches none
of the 23 files; the build, tests and gates above ran after it.
- Commit trailers follow AGENTS.md's model-free pair (`Claude-Session`
plus `Co-authored-by: Claude`); the pre-push trailer check passed on
every push.

## Acceptance notes

**What stays for this card** (why it says `Part of`):
`authoring-rules.ts`, 5 sites, excluded while PR objectstack-ai#20593 holds it.
Anchors, verified the same way, for whoever takes it after that PR
lands: `:198` objectstack-ai#10064 to `def0d3e63`; `:1117` objectstack-ai#16659 to `ecdfc9411` (it
added `flow-schedule-organization-missing` to the registry); `:1687`
"(PR objectstack-ai#8546)" to `ba5e957ef`, that PR's own squash commit; `:1713` and
`:1733` objectstack-ai#19370 to `a227afa41`.

**Form D, not touched:** `validate-react-page-props.ts:1198` is the
`react-prop-deprecated` finding `message`, which ends "...is removed
after the deprecation window (objectstack-ai#11284)." An author sees it, so it takes
ruling D (no number), which is a string change and outside this
comment-only scope. `scripts/doc-authoring-prose-id.baseline.json` pins
it (`objectstack-ai#11284: 1` for this file). It needs a form-D carrier.

**Outside the census's surface**, which blanks strings and defers test
files (noted, not swept here):
- `packages/lint/src/*.test.ts` titles and comments still cite several
of these dead numbers (objectstack-ai#6290, objectstack-ai#8404, objectstack-ai#8663, objectstack-ai#10001, objectstack-ai#10064, objectstack-ai#10262,
objectstack-ai#13681, objectstack-ai#19370 and others).
- Hand-written docs pages cite them too:
`content/docs/automation/hook-bodies.mdx` (objectstack-ai#8663, objectstack-ai#13657),
`content/docs/automation/flows.mdx` (objectstack-ai#11060) and
`content/docs/deployment/validating-metadata.mdx` (objectstack-ai#19370).
- `packages/formula/src/cel-engine.ts` cites objectstack-ai#6290 four times, including
the docblock `validate-expressions.ts:770` quotes. It is in the census,
in another lane's package.

**Wording, each true of its commit.**
- `validate-expressions.ts:571` keeps objectstack-ai#6584 beside `e9b526597`: objectstack-ai#6584 is
that commit's own PR, so "arrived in commit e9b5265, and needed that
same change (objectstack-ai#6584) to be noticed" states the one act both old numbers
named.
- `runtime-gate.ts:362` names the objectstack-ai#9798 shape in words, as the fail-open
that commit c7655d4 ended, next to objectstack-ai#9261 and ADR-0110 D3.
- `lint-flow-patterns.ts:343` reads "The measured case commit 8ed9c54
records, exactly: one row with a null owner killed the sweep"; that
commit wrote the sentence, and `c02f70e13` later fixed the same shape in
the showcase flow.

---
_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
…les.ts to the commits that decided them (objectstack-ai#20631)

Part of objectstack-ai#20597
Clause-②: no

Stage 2 of the `packages/lint` dead-citation sweep (claim `5888191846`).
Stage 1 (PR objectstack-ai#20612) left `packages/lint/src/authoring-rules.ts` at its
base blob while PR objectstack-ai#20593 held the file. That PR has landed
(`e651556e2d`). Each of the file's five comment and docblock lines that
cited a tracker number answering 404 now cites, in ruling C+D's form C,
the commit in this repository's history that decided what the line
states. Each line still says what was decided. Comments only: 5 lines
out, 5 in, in one file, plus one `@objectstack/lint` `patch` changeset.

This PR says `Part of`: the form-D finding-message string at
`validate-react-page-props.ts:1198` (`(objectstack-ai#11284)`, shown to authors) stays
on the card as a separate decision. It is byte-identical here (see
Acceptance notes).

## Measurement

The instrument is the gate's own `node scripts/check-issue-citations.mjs
--census --json`, filtered to `packages/lint/`.

- **Before:** base `1322cc72c9`, 2026-09-29T10:18:50Z to 10:22:33Z,
board enumerated (185 pages, frontier objectstack-ai#20627). Repo-wide
`allocated-but-absent` 2,209.
- **After:** head `2e1955b492`, 11:13:21Z to 11:16:47Z (185 pages,
frontier objectstack-ai#20630). Repo-wide `allocated-but-absent` 2,204, exactly 5
fewer. No finding at head is absent at base.

| site in `authoring-rules.ts` | before | after | anchor |
|---|---|---|---|
| `:201` (the `AuthoringFinding.path` docblock) | objectstack-ai#10064 | commit |
`def0d3e63` |
| `:1123` | objectstack-ai#16659 | commit | `ecdfc9411` |
| `:1722` | `(PR objectstack-ai#8546)` | commit | `ba5e957ef` |
| `:1748` | `[objectstack-ai#19370]` | commit | `a227afa41` |
| `:1768` | `[ADR-0090 D3 / objectstack-ai#8310 → objectstack-ai#19370]` | commit | `a227afa41`
(ADR-0090 D3 and objectstack-ai#8310 stay) |
| **`packages/lint` total** | **5** | **0** | 4 numbers, to 4 distinct
shas |

The five lines on `origin/main` `e651556e2d` are the same lines at the
base: `packages/lint` is byte-identical between `e651556e2d` and
`1322cc72c9`.

## Why each anchor decides its line

Each sha resolves uniquely (`rev-parse --disambiguate` gives 1 object).
Each is single-parent. `merge-base --is-ancestor` exits 0 against
`origin/main` and against the base, and the repository is not shallow.
Each commit's own diff was read for the rule its line states.

- **objectstack-ai#10064 to `def0d3e63`**: "key collection-resident publish-gate
finding paths by name, not the private snapshot index". Its body names
objectstack-ai#10064 as the card it lands, "(maintainer ruling 2026-08-20: Option A)".
Its own diff wrote this very docblock: the positional-as-rules-emit-it
sentence, the `objects.acme_invoice.sharingModel` example and the
pointer to `nameKeyFindingPath`, which the same commit introduced in
`runtime-gate.ts`.
- **objectstack-ai#16659 to `ecdfc9411`**: the squash commit that declares a
time-triggered flow's acting organization. Its diff adds
`FLOW_SCHEDULE_ORGANIZATION_MISSING`
(`flow-schedule-organization-missing`, at `warning`) to
`validate-flow-trigger-readiness.ts`. It also wrote the
`authoring-rules.ts` sentence "... added a sixth id,
`flow-schedule-organization-missing`, at `warning`" that this line
opens, and its sub-commits name objectstack-ai#16659. The live objectstack-ai#17396 retirement
beside it stays.
- **`(PR objectstack-ai#8546)` to `ba5e957ef`**: PR objectstack-ai#8546's own squash commit,
"permission/book cross the runtime publish gate; object measured dirty
stays behind". Its `authoring-rules.ts` diff changes `runtimeTypes:
['seed']` to `['seed', 'permission', 'book']`, which is objectstack-ai#8310 slice 1 as
the line states. The live objectstack-ai#8310 stays.
- **objectstack-ai#19370 to `a227afa41`** (two sites): "`security-role-word` crosses
to the runtime publish gate, whole". Its body names objectstack-ai#19370 as the card
it lands. Its own `authoring-rules.ts` diff wrote both lines: "[objectstack-ai#19370]
It has since crossed, also whole, on its own entry" and the `[ADR-0090
D3 / objectstack-ai#8310 → objectstack-ai#19370]` marker. Stage 1 cited the same commit for the same
number in `runtime-gate.ts` and `validate-security-posture.ts`.

Rung: no file under `docs/adr/**`, `docs/NORTH-STAR.md` or
`scripts/adr-anchors/` names any of the four numbers. So the commit rung
is right, as in stage 1. ADR-0090 D3 already stands on `:1768` and is
kept.

## Mechanical proof

- **Token and residue guard.** A scratch instrument on the TypeScript
6.0.3 parser compares the base blob with the head blob at two levels.
The first is leaf AST tokens, with JSDoc nodes excluded. The second is
the non-comment residue: every comment range dropped, everything else
compared byte for byte. The controls mutate the head text in memory
only.
- Real run: 3,543 tokens at base and at head, tokens EQUAL, residue
EQUAL (exit 0).
- Dark control, a whole comment line inserted: tokens EQUAL, residue
EQUAL, comment ranges 957 to 958 (exit 0).
- Lit control, a code statement inserted: DIFFER at token 0, residue
DIFFER (exit 1).
- Lit control, one character inserted into a parser-located string
literal: DIFFER at token 5, residue DIFFER (exit 1). The first string
control was a no-op and is void: it searched by text and landed in a
comment, reading EQUAL. It was re-anchored on a parser-located literal
and re-run. The mutation was confirmed landed.
- **Line balance**: +5/−5, and every changed line is comment-shaped. The
file has 2,011 lines at base and at head.
- **Tracker numbers**: removed objectstack-ai#10064, objectstack-ai#16659, objectstack-ai#8546 and objectstack-ai#19370 ×2. The
added lines carry only the live objectstack-ai#8310 ×2, which stands on both the
removed and the added side of `:1722` and `:1768`. So added-not-removed
is empty, and no `PR #N` stands on an added line. There are 215 `#N`
tokens at base and 210 at head.
- **Shas**: 4 distinct on added lines (`a227afa41` ×2), none on removed
lines.
- **Literal readers**: `scripts/doc-authoring-prose-id.baseline.json`
pins this file's string sites as objectstack-ai#4463, objectstack-ai#4716, objectstack-ai#4717, objectstack-ai#7220, objectstack-ai#8309 and
objectstack-ai#9698, none of them these four numbers. `check-docs-transcript-drift`
loads the registry module, not its comments.

## Tests and gates (at head `2e1955b492`)

- Build under `os-verify-lock`: `pnpm exec turbo run build
--concurrency=2 --filter=./packages/* --filter=./packages/*/*`. The last
run printed Tasks 71 successful, 71 total, and VERDICT command-exit 0.
It took three attempts inside a 270 s timeout on a shared box. The first
two were cut off at 39 of 46 and 66 of 68 tasks, and turbo's cache
carried their finished tasks forward.
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` under
the lock: Test Files 115 passed (115), Tests 5379 passed (5379), VERDICT
command-exit 0.
- `pnpm --filter @objectstack/lint typecheck` under the lock: exit 0.
`check:test-typecheck` OK (2 files, 6 errors, 2 pinned signatures held).
VERDICT command-exit 0.
- Lint, as a proven narrowing: `eslint --no-inline-config --format json
packages/lint/src/authoring-rules.ts` reports 1 file, 0 errors, 0
warnings. `isPathIgnored` is false, read through eslint's API.
`eslint.config.mjs:327-328` says type-aware linting is never enabled, so
a comment edit cannot move an untouched file's verdict. The repo-wide
`pnpm lint` is CI's.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 54 families, and all 54 ran with exit 0. `--ran`
reads "54 derived, 54 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero.
Among them:
- `node scripts/check-issue-citations.mjs`, the live diff-scoped run,
judged 2 citations in 1 file, the kept objectstack-ai#8310 ×2, and both answer as
issues. `pnpm check:issue-citations` passes its self-test (114 cases in
8 batteries).
- `pnpm check:doc-authoring`: the sibling prose-id baseline holds, 810
pinned sites across 230 files, no growth.
- `pnpm check:nul-bytes`: OK over 9,253 tracked text files. A
control-byte scan of both changed files finds none.
- Generated pages: none to regenerate. No page under
`content/docs/references/` names the four numbers, `AuthoringFinding` or
`nameKeyFindingPath`, and no generator reads `packages/lint/src`.
- Changeset: `patch` for `@objectstack/lint`, a new file (stage 1's
`lint-provenance-anchors.md` is untouched). `files[]` ships `dist`, and
the rewritten comments reach it:
- `commit def0d3e` is in the `AuthoringFinding` docblock of
`dist/runtime-*.d.ts`, beside the unchanged "Positional as RULES emit
it", the positive control.
- `commit ba5e957` (cited only here) and `commit a227afa` are in
`dist/index.js` and `dist/index.cjs`.
  - None of the four numbers remains in `dist`.
- Merge probe: a no-driver `merge-tree` of the head onto `origin/main`
`542670da6d`, from a bare shared clone with no `merge.*` config, exits
0. None of the three commits `main` gained since the base touches
`packages/lint`.
- No ablation or reverse verification: the change is comment-only, so
there is no behaviour to invert.

## Hypotheses (measured first)

1. **Holds.** At the base the census reads exactly 5 dead sites in
`packages/lint`, all in `authoring-rules.ts`, and after the change it
reads 0. The card's sixth site, the `(objectstack-ai#11284)` string at
`validate-react-page-props.ts:1198`, is outside the census because the
census blanks string literals. It was read directly: still present, and
the file is byte-identical from base to head.
2. **Holds.** The five sites read `:201` objectstack-ai#10064, `:1123` objectstack-ai#16659, `:1722`
`(PR objectstack-ai#8546)`, `:1748` and `:1768` objectstack-ai#19370, on `e651556e2d` and at the
base alike. All four anchors were re-verified above from their own
diffs, not copied.
3. **Holds.** PR objectstack-ai#20593's new lines cite objectstack-ai#20553, objectstack-ai#20611 and objectstack-ai#20552 (and
ADR-0041). All three answer 200, and the census finds no dead site on
them. None of the five lines' sentences changed in meaning. The one
adjacency is described under Acceptance notes.

## Deviations

- Commit trailers follow AGENTS.md's model-free pair (`Claude-Session`
plus `Co-authored-by: Claude`), not the model-named trailer the harness
reminder suggested. The pre-push trailer check passed on both pushes.
- The first lit string control was a no-op: it searched by text and
landed in a comment. It is reported void above and was re-run on a
parser-located literal.

## Acceptance notes

**Form D, not touched (why this PR says `Part of`):**
`validate-react-page-props.ts:1198` is the `react-prop-deprecated`
finding `message`. It ends "...is removed after the deprecation window
(objectstack-ai#11284)." An author sees it, so it takes ruling D (no number). That is
a string change, outside this comment-only claim.
`scripts/doc-authoring-prose-id.baseline.json` pins it (`objectstack-ai#11284: 1` for
that file), and that baseline is shrink-only.

**An ordinal beside PR objectstack-ai#20593's insertion, kept verbatim:** the
paragraph above `:1123` now ends "objectstack-ai#20553 made it five", counting the
rules that emit `error`. `:1123` reads "Commit ecdfc94 added a sixth
id", an ordinal that commit wrote itself. The two count different
things: rules that emit `error`, and ids in the rule file. The ordinal
is also imprecise on its own terms, because
`validate-flow-trigger-readiness.ts` exported six ids before
`ecdfc9411`, so the new one was its seventh. This PR moves only the
tracker number, so the word stays as written.

**Outside the census's surface (noted, not swept):** stage 1 notes that
lint test titles and hand-written docs still cite these numbers.
`content/docs/deployment/validating-metadata.mdx` cites objectstack-ai#19370 at
`:472`, `:483` and `:515`.

---
_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/l tests tooling

Projects

None yet

2 participants