Skip to content

fix(spec)!: close the shared retry policy, and judge try_catch config keys at the build doors - #22380

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22343-try-catch-retry-strict
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22343-try-catch-retry-strict

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22343

Clause-②: no (narrowing: an undeclared key on the shared retry policy is refused at parse wherever it is written, a job's retryPolicy and a try_catch node's retry, and an undeclared try_catch config key at the build doors and the save door, where each passed)

The claim (6069618073) declared yes. A pure narrowing is no (references/execution-duties.md:105), so the seat corrected the spelling after the contract review 6072339018; the parenthesis is unchanged. It is worded to what the census decided: no writer relies on the strip, so the shared RetryPolicySchema is closed and every one of its parsers narrows, not the try_catch slot alone. Per triage 6068085630, the importers named that way are job.retryPolicy and a try_catch node's retry.

What changes

  • Spec, shared/retry-policy.zod.ts. RetryPolicySchema is a strictObject (it was a plain z.object). A key it does not declare is refused at parse, naming the key, with a curated near-miss table: maxRetry, retries and attempts → maxRetries; initialDelayMs and baseDelayMs → backoffMs; maxDelayMs → maxRetryDelayMs. A maxAttempts gets a prescription instead of an alias, because it counts the first attempt. The retryDelayMs tombstone stays, with its rename. The docblock records the census.
  • Spec, automation/flow-node-config-refusals.ts. BUILTIN_KEYS_JUDGED_AT_REGISTRATION is deleted. try_catch was its only member, and an empty exception set would be dead code. builtinNodeConfigKeysJudged(type) is now getBuiltinNodeConfigContracts().has(type), so the key arm judges all 13 builtins. The key arm also refuses a tombstoned key the author wrote, at its own path and in the tombstone's words. script is excluded (RETIRED_KEYS_JUDGED_ELSEWHERE), because the lint names its retired keys and no door before the run ever judged them. See "Route change" below.
  • service-automation engine.ts: comments only. validateNodeConfigKeys already asks builtinNodeConfigKeysJudged, so it now stands aside for try_catch and judges plugin node types only. Its docblocks are corrected.
  • Ledger. A step-18 D3 entry, try-catch-and-retry-policy-undeclared-keys-refused, plus a STEP18_RATIONALE fragment with order 90; the highest order on main was 89. migrations/registry.ts was regenerated with gen:migration-registry. The step-18 entry flow-builtin-node-config-undeclared-keys-refused and its fragment said try_catch "stays registration's", which this change makes false in the same unreleased step, so both now point at the new entry.
  • Changeset. .changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md bumps @objectstack/spec minor (BREAKING, ADR-0087 registered) under the same pre-mode convention as 2f70c2222 (.changeset/pre.json: mode pre, tag next). It carries the Clause-② line above. It has no @objectstack/service-automation entry: that package's diff is comments plus one test, and no gate asked for one (check-changeset-no-major, check-adr-0087-registration and check-empty-changeset are green).
  • Docs. content/docs/automation/flows.mdx: the config row and the strictness callout now list try_catch among the judged builtins, and one sentence says retry is closed. jobs.mdx gets one sentence saying retryPolicy is closed. The strictness ledger's audit row (docs/audits/2026-07-unknown-key-strictness-ledger.md) said the policy "is still non-strict", which is no longer true, so it is corrected.
  • In-place fix, automation/flow.zod.ts (seat order 6071818310). Flow.errorHandling's refusal of maxAttempts said a bare rename to maxRetries would run "one attempt fewer" than asked for. It runs one more: maxAttempts: 3 is 3 runs, and maxRetries: 3 is 1 + 3 = 4. The clause now says "one attempt more". The prescription maxRetries: <maxAttempts - 1> is unchanged. The flow.test.ts case is now the pin, and its comment, which carried the same inversion, is corrected. The RetryPolicySchema pin in retry-policy.test.ts gains the same two assertions, so both surfaces assert the same four things: the prescription, "one attempt more", never "fewer", and no bare rename. The changeset gains one Also corrected: line.

Census (H1), at origin/main b7fdd5085

Parsers of RetryPolicySchema:

site parses authored by
system/job.zod.ts:366 JobSchema.retryPolicy yes stack jobs
automation/control-flow.zod.ts:330 TryCatchConfigSchema.retry yes (the executor, and the key/value arms) flow try_catch nodes
automation/flow.zod.ts:1224 Flow.errorHandling spreads retryPolicyShape() into its own strictObject, already closed unaffected
integration/connector-fetch-policy.ts:49 comment only no parse
contracts/job-service.ts:131 JobRetryPolicy TS interface mirror no parse
service-job run-with-policy.ts:17 mirrors the defaults, reads an already-parsed policy no parse

Writers checked for a key outside the five (maxRetries, backoffMs, backoffMultiplier, maxRetryDelayMs, jitter):

  • In this repo: examples/app-showcase jobs (:22) and flows (:1212, :1425), examples/app-todo, content/docs/automation/flows.mdx (:798, :1856) and jobs.mdx (:25, :243), the service-automation and service-job READMEs, every test fixture that writes retry: under a try_catch or a job retryPolicy: (lint, service-automation, service-job, spec), and the retry-policy-converged conversion's output and fixtures. 0 undeclared keys.
  • objectui at the pinned a58626c88d: preview-samples.ts:331 (retryPolicy: { maxRetries: 3 }); the flow inspector's fallback try_catch form, which writes errorVariable only; and the descriptor-driven form, which reads the descriptor that closes retry to the five keys. 0 undeclared keys.
  • Out of reach: no connector manifest, seed or sys_metadata writer of this shape was found. hotcrm and deployed metadata were not measured.

Verdict: no writer relies on the strip, so the shared schema is closed. No writer sits outside packages/spec, service-automation or service-job, so the stop condition does not apply.

Hypotheses, measured

  • H1, confirmed. See the census above.
  • H2, confirmed, with one addition (see "Route change"). With retry strict, the key arm judges try_catch like the other 12. Docblocks that named the exception were corrected in flow-node-config-refusals.ts (module header, the value-arm carve-out, the predicate, the main arm's bullet, the inline comment), in flow.zod.ts (two comments), in engine.ts (three comments), and in the two spec test headers.
  • H3, confirmed. A probe over installBuiltinNodes lists the 17 builtin descriptors with their configSchema and judged state. Before: the walk still reached ["try_catch"]. After: []. The remaining builtins publish no configSchema (decision, wait, connector_action) or are exempt (assignment). "Refused once" is pinned in config-unknown-keys.test.ts: one parse issue, and no undeclared config key(s) text from the walk.
  • H4, falsified as stated: the did-you-mean needed a curated row. The distance fallback cannot reach maxRetry. It is 3 edits from maxRetries (y→i, +e, +s) against a budget of 2 for an 8-letter key: findClosestMatches('maxRetry', …, 2) returns []. The measured door first printed the refusal with no did-you-mean. It now answers `maxRetry` → `maxRetries` through an aliases row, the mechanism Flow.errorHandling already uses for the same vocabulary. The refusal is located (nodes.N.config.retry.maxRetry, and jobs.N.retryPolicy for a job) and uses the node-config-refused-by-contract shape from build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982.

Route change: the retryDelayMs tombstone (registration would otherwise have widened)

The key arm judged unrecognized_keys only, and both arms skip a tombstone (invalid_type expecting never). The retry-policy-converged conversion renames retryDelayMs to backoffMs, but it keeps both spellings when the values differ, and it leaves a null. The walk refused what survived, as an undeclared key. With the walk standing aside and the arms unchanged, registerFlow would have accepted those two variants and the run would have refused them. So the key arm now refuses a tombstoned key the author wrote on every judged type. script is the exception, so this change narrows nothing on any other builtin: only script and try_catch carry retiredKey() in their contracts, measured by grep over the five contract modules. The ruling's intent ("one judge", and registerFlow widens nowhere) is what decided this. It is named here for the contract review.

Registration verdicts, before and after (10-variant probe on the built packages)

try_catch config before: registerFlow after: registerFlow
declared retry registers registers
retry.bogusKey refused, walk refused, parse at nodes.1.config.retry.bogusKey
retry.maxRetry refused, walk refused, parse at nodes.1.config.retry.maxRetry
retryDelayMs alone registers (converted) registers (converted)
retryDelayMs equal to backoffMs registers (twin dropped) registers (twin dropped)
retryDelayMs differing from backoffMs refused, walk refused, parse at …retry.retryDelayMs
retryDelayMs: null refused, walk refused, parse at …retry.retryDelayMs
top-level bogusKey refused, walk refused, parse at nodes.1.config.bogusKey
try.bogusKey (region) refused, region check refused, region check
retry: 5 refused, parse refused, parse

Every variant registers or is refused exactly as before; only the judge moved. A direct FlowSchema.parse or defineFlow() (no conversion) now meets the tombstone for a pre-17 retryDelayMs, like every other retired spelling.

Measured at the CLI door

Fixture projects (defineStack(…, { strict: false })), at 9f71db481, with the built CLI closure:

fixture os validate --json
try_catch retry: { maxRetry: 2 } exit 1, custom at flows.0.nodes.1.config.retry.maxRetry
job retryPolicy with maxRetry: 3 exit 1, unrecognized_keys at jobs.0.retryPolicy
every declared key exit 0
pre-17 retryDelayMs alone exit 0 (retry-policy-converged converted it)

examples/app-showcase validates clean (exit 0, valid: true; 2 try_catch with retry, 1 job with retryPolicy), and so does examples/app-todo (exit 0).

Ablation

At HEAD 9f71db481, RetryPolicySchema was mutated back to a plain z.object through scripts/ablation-replace.mjs: anchor 1 → 0, blob c06e5bd1a6 → dbd76790f2. The spec was rebuilt, and ablation-dist-preflight found the marker present in 20 built files. Results:

  • validate-door pin (packages/cli/test/retry-policy-key-validate-door.test.ts): 1 failed, 1 passed. The refusal went red (exit 0 where exit 1 was expected) and the control held.
  • spec pins: 6 failed, 49 passed.
  • registration pins: 2 failed, 14 passed. These include "registerFlow widens nowhere": with the strict policy reverted, the arm sees no unknown key and the walk stands aside, so the mutation is exactly the widening the card names.

Restore: the blob is back to c06e5bd1a6 == HEAD and git diff HEAD is empty. After a rebuild, the preflight with --absent found the marker in none of 232 built files and the tree clean, and the door pin was green again (2 passed).

Tests (real readings)

At b7fdd5085 (merged with origin/main 6a53564b9):

  • @objectstack/spec: 626 files, 18751 passed, 1 todo.
  • @objectstack/service-automation: 178 files, 2179 passed.
  • @objectstack/lint: 128 files, 5853 passed.
  • CLI door pin, --project integration: 2 passed.
  • typecheck for spec, service-automation and cli: exit 0. The test-layer debt is unchanged (spec 52 files / 246 errors held, cli 3 / 28).

At c915191dc (the in-place fix round, no merge of main): @objectstack/spec src/automation plus retry-policy.test.ts, 34 files, 1134 passed, and the spec typecheck exits 0 (debt held at 52 / 246). Ablation: putting back "fewer" with scripts/ablation-replace.mjs turned the flow.test.ts pin red (1 failed). The blob was restored to c4c6c4453d == HEAD.

At a77cb02b1: @objectstack/service-job 11 files, 117 passed. The @objectstack/cli unit tier, 270 files, 3968 passed; it includes the tier-partition pin, and the new door pin lands in integration by behaviour. It runs per PR because it is not named .e2e.

Narrowed eslint (--no-inline-config) over the 12 changed .ts files: 12 files, 0 errors, 0 warnings, none ignored. This config enables no type-aware linting (eslint.config.mjs:327), so the diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.

Gates

node scripts/pm/dispatch-gates.mjs --commands at b7fdd5085 derived 115 commands, the same list as at a77cb02b1, and all 115 ran with exit 0. The --ran verdict: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero — all 115 recorded an exit code and none of them is 3).

At c915191dc, the round's 4 paths derived 85 commands, every one of them already among the 115. The full derivation is unchanged at 115. All 85 exited 0 (--ran: 85 run, 0 NOT-MEASURED). Ten dist-reading gates first exited 3 on a fresh worktree, then exited 0 after a build.

Two gates needed a second run:

  • check:type-check-debt hit the per-command 300 s cap. Re-run alone, it exited 0 in 123 s.
  • At a77cb02b1, five spec gates exited 3 (spec dist predated a test edit) and check:dual-build-cjs-loads exited 3 (8 packages had no dist). After building them, all six exited 0.

The roster gates whose roster sits under a touched directory also exited 0: check:meta-url-spelling, check:authz-resolver, check:error-code-casing and check:filter-alias-parity.

Acceptance notes

  • Flow.errorHandling gets no did-you-mean for maxRetry, the same distance gap closed here on the shared policy. Its own alias table has no such row. It is a sibling surface outside this card's scope, and the key is still refused loudly. Carrier: none.
  • A script node carrying a retired dispatch key (actionType, …) is refused at error, with exit 1, by os validate and os lint (rule expression-invalid, measured at 6fbc005a6). The save door answers 200 and stores the body with the key removed: the retired conversion flow-node-script-branch-keys-removed replays on the save path and logs OS_METADATA_CONVERTED. So nothing reaches the run from either door. defineStack and a direct registerFlow were not measured. RETIRED_KEYS_JUDGED_ELSEWHERE keeps the key arm out of script deliberately. Carrier: none (finding 9 of the contract review 6072339018, measured in round 3 6073081304).
  • Two runtime test comments still say the registration walk judges "only those (and try_catch)": packages/runtime/src/domains/automation-put-post-error-parity.test.ts:50 and automation-register-error-class.test.ts:79. After this PR, the walk judges plugin node types only. They are comments in test files that do not ship. Carrier: the next edit of either file.

Line budget

17 files, +846 / −143 against the merge base (the in-place fix round: 4 files, +13 / −4; round 3: the changeset's Clause-②: value, +1 / −1). 0 governed paths.


Generated by Claude Code

claude added 7 commits October 8, 2026 22:05
…pec key arm

WIP: the shared retry policy becomes a strictObject, so an undeclared
retry key is refused at parse with a did-you-mean; try_catch leaves the
registration-time descriptor walk and joins the builtin key arm, which
also refuses a retryDelayMs tombstone the conversion leaves behind.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
…y; curate the policy's near-miss table

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
…at the build doors; changeset

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
…t the value arm's

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue))
  • content/docs/api/error-handling-client.mdx (via maxRetries (literal, a string literal in RetryPolicySchema))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue))
  • content/docs/automation/approvals.mdx (via try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const), RetryPolicySchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine), backoffMs (literal, a string literal in RetryPolicySchema), invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue), maxRetries (literal, a string literal in RetryPolicySchema), maxRetryDelayMs (literal, a string literal in RetryPolicySchema), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/automation/jobs.mdx (via backoffMs (literal, a string literal in RetryPolicySchema), maxRetries (literal, a string literal in RetryPolicySchema), maxRetryDelayMs (literal, a string literal in RetryPolicySchema))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue))
  • content/docs/protocol/kernel/error-handling.mdx (via maxRetries (literal, a string literal in RetryPolicySchema))
  • content/docs/protocol/kernel/index.mdx (via maxRetries (literal, a string literal in RetryPolicySchema))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue))

⛔ 6 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), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine), invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue), maxRetries (literal, a string literal in RetryPolicySchema))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class), invalid_type (literal, a string literal in builtinValueJudged; a string literal in isRetiredKeyIssue), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const), 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
  • 6 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 — 139 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 c8c803c293af7fdcba18b142e64ee9ce80bfc198 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c8c803c293af7fdcba18b142e64ee9ce80bfc198

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

…e runs one attempt more, not fewer

maxAttempts counts the first attempt and maxRetries does not, so
maxAttempts: 3 is 3 runs and a bare rename to maxRetries: 3 is 4. The
prescription (maxRetries one lower) was right; the consequence clause was
inverted. Pinned on both surfaces: the message says "one attempt more"
and never "fewer".

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #22343 (body and its four comments: triage 6068085630, claim 6069618073, os-dev-report 6071795385, seat review 6071818310), PR #22380 (body, 17-file list, net diff against merge base 6a53564b9, +846 / -143), and the 42 check-runs on the head, read in this act at 2026-10-09T01:23Z. All 42 are completed; none is pending. The seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) are success. Four read skipped, none of them a gate: Console Pin Gate (no .objectui-sha move in the diff), Packed-tarball smoke (opt-in), and the second, path-scheduled run of Auto Label and Check PR Size. Head repo equals base repo (not a fork). No governed path among the 17 files.

① Derived judgments

  1. RetryPolicySchema (shared/retry-policy.zod.ts) plain z.object → strictObject with an alias table (maxRetry, retries, attempts → maxRetries; initialDelayMs, baseDelayMs → backoffMs; maxDelayMs → maxRetryDelayMs) and a maxAttempts guidance entry. Narrowing of both of its parsers. Importer census re-read at the head: the only parse sites are system/job.zod.ts:366 (JobSchema.retryPolicy) and automation/control-flow.zod.ts:330 (TryCatchConfigSchema.retry); automation/flow.zod.ts:1224 spreads retryPolicyShape() into its own already-strict Flow.errorHandling (unaffected); contracts/job-service.ts, service-job run-with-policy.ts, service-automation try-catch-node.ts and engine.ts are mirrors, comments or reads of an already-parsed policy; system/worker.zod.ts has its own TaskRetryPolicySchema (untouched). The Automation and System re-exports are one declaration (pinned from both entries in retry-policy.test.ts). The retryDelayMs tombstone keeps its own invalid_type refusal rather than becoming an unknown key (pinned). RIGHT — named in the changeset's "What is refused" and in the Clause-② parenthetical.

  2. builtinNodeConfigKeysJudged('try_catch') false → true; BUILTIN_KEYS_JUDGED_AT_REGISTRATION (unexported) deleted. So flowNodeConfigRefusals('try_catch', …) now emits node-config-refused-by-contract at nodes.N.config.bogusKey and nodes.N.config.retry.maxRetry, with the alias did-you-mean (maxRetry is three edits from maxRetries, past the distance budget of two — the alias row is the right mechanism, the one Flow.errorHandling already uses). Narrowing at FlowSchema.parse, defineFlow(), defineStack (STACK_SCHEMA_INVALID 422), objectstack validate (exit 1, pinned at the real CLI in packages/cli/test/retry-policy-key-validate-door.test.ts), objectstack compile and the save door. At registerFlow the descriptor walk refused the same keys before, so the accept set there is unchanged — pinned on four refusal variants plus two controls in config-unknown-keys.test.ts, and the walk's own "undeclared config key(s)" text no longer appears. A key on the try region object stays the region check's, as on loop / parallel (control pinned). RIGHT.

  3. The key arm now refuses a tombstoned key (retiredKey(), invalid_type expecting never) on every judged builtin except script (RETIRED_KEYS_JUDGED_ELSEWHERE), skipping region-slot paths — the "Route change" the PR body flags. Reach verified at the head, not taken from the body: across the four contract modules the map in flow-node-config-refusals.ts:85-98 is built from (builtin-node-config.zod.ts, io-node-config.zod.ts, schemaless-node-config.zod.ts, control-flow.zod.ts) and every shape they import, the only retiredKey() inside a builtin config contract are script's five (schemaless-node-config.zod.ts:312-346, excluded by the map) and try_catch.retry.retryDelayMs through the shared policy. The three tombstones in flow.zod.ts (outputSchema :703, waitEventConfig.timeoutMs / .onTimeout :773 / :784) sit on the node shell, judged by FlowSchema's own strict shell, and reach a config contract only as region nodes under a region slot, which the new branch skips (unknownKeyUnderRegionSlot); shared/mapping.zod.ts:93 (transform) is imported by none of the four modules. So the branch's one new refusal is try_catch.retry.retryDelayMs: the copy the retry-policy-converged conversion leaves (beside a differing backoffMs, or null) is refused at every door where the walk refused it at registration (registration unchanged, pinned); the pre-17 spelling alone is converted first at every converting door (defineStack control pinned, CLI control pinned) and meets the tombstone only at a direct FlowSchema.parse / defineFlow() — a narrowing at those two doors on a key retired in 17.0.0, which the changeset states ("Met by a direct FlowSchema.parse or defineFlow(), it meets its tombstone") and whose FROM → TO row is present. RIGHT; one note for the author, not a verdict matter: that sentence is filed under "What stays as it was", and at those two doors it is a change — the sentence itself is accurate and the reader lands on the right fix.

  4. service-automation engine.ts: comments only. validateNodeConfigKeys reads the predicate, so it stands aside for try_catch with no code change; the body's probe that no builtin reaches the walk any more is consistent with the map (the builtins not judged — decision, wait, connector_action — publish no configSchema; assignment is exempt). RIGHT.

  5. Flow.errorHandling maxAttempts guidance: "one attempt fewer" → "one attempt more" (the seat's in-place order 6071818310). Direction checked: maxAttempts: 3 is three runs; a bare rename to maxRetries: 3 is one plus three, four runs — one more. The prescription (maxRetries equal to maxAttempts minus one) is unchanged. Message-only change on an existing refusal; the flow.test.ts case is now a pin (not.toMatch(/fewer/i)), mirrored on the new policy's guidance in retry-policy.test.ts, and the changeset carries the "Also corrected" line. All three parts of the order are present. RIGHT.

  6. isRetiredKeyIssue refactor inside builtinValueJudged — same predicate, no change in the value arm. RIGHT.

  7. Ledger. New step-18 D3 entry try-catch-and-retry-policy-undeclared-keys-refused (no D2 conversion, no tombstone, no RETIRED_KEYS_BY_MAJOR row), STEP18_RATIONALE fragment at order 90, the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 entry and its fragment corrected so step 18 no longer says try_catch "stays registration's"; migrations/registry.ts regenerated (the required TypeScript Type Check job, which runs check:generated, is green). Reach: job is a metadata type (metadata-plugin.zod.ts:933, file patterns *.job.*, allowRuntimeCreate: false), so a file-loaded or stored job with an undeclared retryPolicy key, stripped before, is now refused at load; the entry's "wherever a job … is authored or stored" plus its acceptance criterion "boot every deployed stack" covers that door. RIGHT.

  8. Docs and audit row. flows.mdx (config row and strictness callout now list all 13 contract-carrying builtins, try_catch region keys named as the region check's, one sentence on the closed retry), jobs.mdx (one sentence), and the strictness ledger's control-flow.zod.ts row ("still non-strict" corrected). Each sentence matches the code above. No release-owned or auto-generated page is edited. RIGHT.

  9. Public surface. No export added or removed (RETIRED_KEYS_JUDGED_ELSEWHERE, isRetiredKeyIssue are module-private); no api-surface/ artifact moves and the gate is green. The only public-surface change is the answer of the exported predicate builtinNodeConfigKeysJudged('try_catch'), item 2. RIGHT.

② Semver level

  • .changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md: @objectstack/spec: minor, a **BREAKING** banner, exactly one ADR-0087 marker (registered try-catch-and-retry-policy-undeclared-keys-refused, matching the ledger entry), a FROM → TO table and the one-line fix. .changeset/pre.json on main reads mode pre, tag next, so the launch-window rule ships an accept-set narrowing as minor (check-changeset-no-major refuses a major in pre-mode). Level and disposition: RIGHT. No changeset for @objectstack/service-automation (comments plus one test file, nothing of its own publishes) or @objectstack/cli (one test file): RIGHT.
  • Clause-② line — the arm is right, the value is wrong. The PR body and the changeset both read Clause-②: yes (narrowing: …). The arm (narrowing) is the diff: every accept-set change in ① is a narrowing. The value yes answers 「本卡放宽接受集或扩大公开面吗」 (scripts/pm/clause2-line.mjs, the one reader), and the reader defines yes (narrowing) as "a diff that widens one surface and narrows another". This diff widens nothing (① item 9: no export, no relaxed accept set anywhere, registerFlow unchanged), and execution-duties.md:105 states the rule for exactly this shape: 「收窄已发布接受集的卡是 Clause-②: no,不移车道;入队前欠一次契约复审档复核」. The card's "or yes (narrowing) if a strip is retired somewhere else too" and the claim's yes read the scope of the narrowing (two parsers) as a widening of the accept set; it is not one, and the contract review was owed on the path limb (packages/spec/src/** non-test) regardless of the value. The line ships verbatim inside CHANGELOG.md, and a released entry is corrected only by a dedicated docs-only PR, so it is corrected before landing, not after. WRONG — the one blocking finding. Fix: Clause-②: no (narrowing: …) in the PR body and in the changeset, parenthetical unchanged; the claim comment is the dispatch's record and stays. Nothing else in this record moves on that push.

③ Boundary flags

open_questions: none declared. Dev flags (os-dev-report 6071795385, PR body), each answered:

  • H4 falsified → curated alias row. Answered, right (① item 2).
  • H2 / H3 route change: tombstoned keys refused by the key arm, script excepted. Answered, right, reach measured at the head (① item 3): the only builtin it touches is try_catch, and registration verdicts are unchanged. Not escalated.
  • File surface beyond the claim (CLI door pin, values-test control re-pointed, the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 entry and fragment, flows.mdx / jobs.mdx, the audit row). Each is a consequence of the change; none is a governed path; none is release-owned. Answered, right.
  • No @objectstack/service-automation changeset. Answered, right (②).
  • Door pin named without .e2e, two CLI spawns, integration tier. Answered, right: it runs per PR under the green Test Core; its one escaping path lands in node_modules, which the cross-package-input gate deliberately does not flag.
  • origin/main merged twice, no os-regen deferral. Answered, right: the regenerated registry is byte-clean under the green check:generated.
  • Commit trailers. Read on all six non-merge commits: the model-free pair the pre-push hook requires (the session link and the bare co-author line), nothing else; no model identifier in the PR body, the changeset or the diff.
  • Out-of-scope (a), the inverted maxAttempts clause. Fixed in place under seat order 6071818310; all three parts verified (① item 5). Answered, right.
  • Acceptance note: Flow.errorHandling gives no did-you-mean for maxRetry. Polish on a sibling surface; the key is still refused loudly. The acceptance note is the right carrier. Answered.
  • Acceptance note: a script node's retired dispatch key (actionType, …) is named by the lint at objectstack validate but accepted by defineStack, the save door and registerFlow, and refused only by the executor at run — read from code, not measured. The PR's scope choice is right: refusing it here would be an undeclared narrowing on script, outside this card. Escalated to the owning domain:spec seat: a retired key accepted at the save door and refused at run is the authoring-trap shape Prime Directive chore: version packages #10 files as a card once it has a repro; measure it at one door (the save door or registerFlow) and file or dismiss it, rather than leaving it as an acceptance note only.

Implemented-by: claude/issue-22343-try-catch-retry-strict
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: FAIL

…narrowing)

The diff widens no accept set and no public surface; a pure narrowing is
spelled `Clause-②: no (narrowing)`. The parenthesis is unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6fbc005a63133309c536215b04a403b9519627e4
Local-runs: none

Inputs, and nothing else: card #22343 (body and its six comments: triage 6068085630, claim 6069618073, os-dev-report 6071795385, seat review 6071818310, the seat's FAIL round order 6072365157, round-3 os-dev-report 6073081304), PR #22380 (body, 17-file list, net diff against merge base 6a53564b9, +846 / −143, fetched into a private ref and read with git diff / git show / git grep only), and the 42 check-runs on the head, read in this act at 2026-10-09T02:45Z. All 42 are completed; none is pending. The seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) are success. Four read skipped, none of them a gate: Console Pin Gate (no .objectui-sha move in the diff), Packed-tarball smoke (opt-in), and the second, later-triggered runs of Auto Label and Check PR Size. Head repo equals base repo (not a fork); the PR is draft with no auto-merge. No file in the diff hits a GOVERNED_SURFACES row (docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md); docs/audits/ is not one. The round-3 delta c915191dc..6fbc005a6 is exactly one line — the changeset's Clause-②: value, yes → no, parenthesis byte-identical — with no merge of main, as the round order required. The net diff's 17 files equal the API file list.

① Derived judgments

  1. RetryPolicySchema (shared/retry-policy.zod.ts): plain z.object → strictObject, with an alias table (maxRetry, retries, attempts → maxRetries; initialDelayMs, baseDelayMs → backoffMs; maxDelayMs → maxRetryDelayMs) and a maxAttempts guidance entry. A narrowing of both of its parsers. Census re-read on the head tree, not taken from the body: the only parse sites are system/job.zod.ts:366 (JobSchema.retryPolicy) and automation/control-flow.zod.ts:330 (TryCatchConfigSchema.retry); automation/flow.zod.ts spreads retryPolicyShape() into its own already-strict Flow.errorHandling (unaffected); system/worker.zod.ts has its own TaskRetryPolicySchema (untouched); contracts/job-service.ts, service-job run-with-policy.ts, service-automation try-catch-node.ts and engine.ts are mirrors, comments or reads of an already-parsed policy. The Automation and System entry exports resolve to the one declaration (export-origins/automation.json, export-origins/system.json), pinned from both entries in retry-policy.test.ts. The retryDelayMs tombstone keeps its own invalid_type refusal rather than becoming an unknown key (pinned). Reach: job is a file-loaded metadata type (kernel/metadata-plugin.zod.ts:933, patterns *.job.*, allowRuntimeCreate: false), so a job artifact carrying an undeclared retryPolicy key, stripped before, is now refused whole at load — stated in the changeset and the D3 entry. RIGHT — named in the changeset's "What is refused" and in the Clause-② parenthetical.

  2. builtinNodeConfigKeysJudged('try_catch') false → true; the module-private BUILTIN_KEYS_JUDGED_AT_REGISTRATION deleted (its only member). flowNodeConfigRefusals('try_catch', …) now emits node-config-refused-by-contract at nodes.N.config.bogusKey and nodes.N.config.retry.maxRetry, with the alias did-you-mean (maxRetry is three edits from maxRetries, past the distance budget of two, so the alias row is the right mechanism — the one Flow.errorHandling already uses). Narrowing at FlowSchema.parse, defineFlow(), defineStack (STACK_SCHEMA_INVALID 422, pinned), objectstack validate (exit 1 at the real CLI, packages/cli/test/retry-policy-key-validate-door.test.ts, with the control fixture exit 0), objectstack compile and the save door (getMetadataTypeSchema('flow'), pinned). At registerFlow the descriptor walk refused the same keys before, so the accept set there is unchanged — pinned on four refusal variants plus two controls in config-unknown-keys.test.ts, and the walk's own "undeclared config key(s)" text no longer appears. A key on the try region object stays the region check's (control pinned), as on loop / parallel. The 13-type contract map at the head lists try_catch with TryCatchConfigSchema, and the judged set is pinned equal to the predicate's answers. RIGHT.

  3. The key arm now refuses a tombstoned key (retiredKey(), invalid_type expecting never) on every judged builtin except script (RETIRED_KEYS_JUDGED_ELSEWHERE), skipping region-slot paths and the judged-whole plugin contract — the "Route change" the PR body flags. Reach verified on the head tree: the builtin map is built from control-flow.zod.ts, io-node-config.zod.ts, schemaless-node-config.zod.ts and builtin-node-config.zod.ts. Every retiredKey() inside a builtin config contract is script's five (schemaless-node-config.zod.ts:312-346, excluded by the map) and retryDelayMs (retry-policy.zod.ts:128) through TryCatchConfigSchema.retry. The flow.zod.ts tombstones — outputSchema :703 and waitEventConfig.timeoutMs / .onTimeout :773 / :784 on the node shell, template :1061 and active :1084 on the FlowSchema shell opened at :987, fallbackNodeId :1242 on Flow.errorHandling — are judged by their own strict shells and reach a config contract only as region nodes under try / catch (control-flow imports FlowNodeSchema for region bodies), which unknownKeyUnderRegionSlot skips; shared/mapping.zod.ts:93 (transform) is imported by none of the four; node-executor.zod.ts:351 (isAsync) is the executor descriptor, not a config. So the branch's one new refusal is try_catch.retry.retryDelayMs: the copy the retry-policy-converged conversion leaves (beside a differing backoffMs, or null) is refused at every door where the walk refused it at registration (registration unchanged, pinned); the pre-17 spelling alone converts first at every converting door (registerFlow calls applyConversionsToFlow at engine.ts:4346 before its parse; defineStack control pinned; CLI control pinned) and meets the tombstone only at a direct FlowSchema.parse / defineFlow() — a narrowing at those two doors on a key retired in 17.0.0, which the changeset states and whose FROM → TO row is present. RIGHT; one note for the author, not a verdict matter: that sentence sits under "What stays as it was", and at those two doors it is a change — the sentence itself is accurate and lands the reader on the right fix.

  4. service-automation engine.ts: comments only (two docblock hunks, no code). validateNodeConfigKeys reads the predicate, so it stands aside for try_catch with no code change; "no builtin reaches the walk any more" is consistent with the map (the builtins not judged — decision, wait, connector_action — publish no configSchema; assignment is exempt). RIGHT.

  5. Flow.errorHandling maxAttempts guidance: "one attempt fewer" → "one attempt more" (the seat's in-place order 6071818310). Direction checked: maxAttempts: 3 is three runs; a bare rename to maxRetries: 3 is one plus three, four runs — one more. The prescription (maxRetries equal to maxAttempts minus one) is unchanged. Message-only change on an existing refusal; the flow.test.ts case is now a pin (contains "one attempt more", never matches "fewer"), mirrored on the new policy's guidance in retry-policy.test.ts; the changeset carries the "Also corrected" line. All three parts of the order are present. RIGHT.

  6. isRetiredKeyIssue factored out inside builtinValueJudged — the same predicate, no change in the value arm. RIGHT.

  7. Ledger. New step-18 D3 entry try-catch-and-retry-policy-undeclared-keys-refused (no D2 conversion, no tombstone, no RETIRED_KEYS_BY_MAJOR row), STEP18_RATIONALE fragment at order 90, the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 entry and its fragment corrected so step 18 no longer says try_catch "stays registration's"; migrations/registry.ts regenerated. check:generated runs check:migration-registry, check:spec-changes and check:upgrade-guide inside the green required TypeScript Type Check job; D3 entries are not projected into spec-changes.json or docs/protocol-upgrade-guide.md (the sibling build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 entry has zero hits in both at the head), so their absence from the diff is consistent. Pinned in flow-builtin-node-config-keys.test.ts. RIGHT.

  8. Docs and audit row. flows.mdx (config row and strictness callout now list all 13 contract-carrying builtins, try_catch region keys named as the region check's, one sentence on the closed retry), jobs.mdx (one sentence), and the strictness ledger's control-flow.zod.ts row ("still non-strict" corrected). Each sentence matches the code above. No release-owned or auto-generated page is edited. On the head tree no remaining text in content/docs, skills/, docs/adr or .claude says try_catch is registration-judged or the policy non-strict, and the deleted constant's name survives nowhere outside tests and changelogs. RIGHT. Residual, not a verdict matter: two runtime test-file comments (packages/runtime/src/domains/automation-put-post-error-parity.test.ts:50, automation-register-error-class.test.ts:79) still say the walk "judges only those (and try_catch)"; they do not ship, and the PR body names the carrier.

  9. Public surface. No export added or removed (RETIRED_KEYS_JUDGED_ELSEWHERE and isRetiredKeyIssue are module-private; the deleted constant was never exported); the RetryPolicy / RetryPolicyParsed types keep their key set; no api-surface/ artifact moves and that gate is green. The only public-surface change is the answer of the exported predicate builtinNodeConfigKeysJudged('try_catch'), item 2. The pinned objectui census (job preview sample, flow designer) is the changeset's stated census, not re-measured here; nothing it could import is removed, so Console Pin Gate's skip is consistent. RIGHT.

② Semver level

  • .changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md: @objectstack/spec: minor, a **BREAKING** banner, exactly one ADR-0087 marker (registered try-catch-and-retry-policy-undeclared-keys-refused, matching the D3 entry id), a FROM → TO table and the one-line fix. .changeset/pre.json on main reads mode pre, tag next, so the launch-window convention (check-changeset-no-major.mjs header) ships an accept-set narrowing as minor with the banner as its carrier. Level and disposition: RIGHT. No changeset for @objectstack/service-automation (docblocks plus one test file) or @objectstack/cli (one test file): RIGHT; Check Changeset is green.
  • Clause-②: line — RIGHT at this head. The changeset and the PR body both read Clause-②: no (narrowing: …). The one reader (scripts/pm/clause2-line.mjs, readArmToken) accepts a colon after the arm word, so the line reads declared, value no, arm narrowing. The diff widens nothing (① item 9) and every accept-set change in ① is a narrowing, which execution-duties.md:105 spells 「收窄已发布接受集的卡是 Clause-②: no,不移车道;入队前欠一次契约复审档复核」; lanes/spec.md:22 adds 「收窄不触发条款②,但按 yes 申报恒不是错误」. The prior record's one blocking finding (6072339018, on c915191dc) is cleared by exactly the one-line change the round order asked for. The (narrowing) arm is BREAKING, and the changeset carries the migration and the single marker that arm requires.

③ Boundary flags

open_questions: none declared in either os-dev-report (6071795385, 6073081304). Dev flags and residuals, each answered:

  • H4 falsified → curated alias row. Answered, right (① item 2).
  • H2 / H3 route change: tombstoned keys refused by the key arm, script excepted. Answered, right; reach measured on the head tree (① item 3): the only builtin it touches is try_catch, and registration verdicts are unchanged. Not escalated.
  • File surface beyond the claim (CLI door pin; values-test control re-pointed; the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 entry and fragment; flows.mdx / jobs.mdx; the audit row; flow.test.ts from the in-place round). Each a consequence of the change; none governed; none release-owned. Answered.
  • No @objectstack/service-automation changeset. Answered, right (②).
  • Door pin named without .e2e, two CLI spawns, integration tier. Answered, right: it runs per PR under the green Test Core; its seed is a recognised spelling, and its one escaping path lands in node_modules, which the cross-package-input gate deliberately does not flag.
  • origin/main merged twice in round 1, no os-regen deferral; round 3 merged nothing. Answered: the regenerated registry is byte-clean under the green check:generated, and the PR reads mergeable: true, state clean, at this reading.
  • Commit trailers. Read on all seven non-merge commits: the session link and the bare co-author line the pre-push hook requires, nothing else; no model identifier in the commits, the diff's added lines, the changeset or the PR body.
  • Round-3 deviation (lock-queue wait, build restored from cache, nothing run unlocked). A process note; no bearing on the verdict.
  • Out-of-scope (a), the inverted maxAttempts clause. Fixed in place under seat order 6071818310; all three parts verified (① item 5). Answered.
  • Acceptance note: Flow.errorHandling gives no did-you-mean for maxRetry. Polish on a sibling surface; the key is still refused loudly. The acceptance note is the right carrier. Answered.
  • Acceptance note, and the prior record's escalation (finding 9): a script node carrying a retired dispatch key (actionType). Measured in round 3 (6073081304): os validate and os lint refuse it at error with exit 1 (rule expression-invalid); the save door answers 200 and stores the body with the key removed — the retired conversion flow-node-script-branch-keys-removed replays on the save path and logs OS_METADATA_CONVERTED — so nothing reaches the run from either door. By the seat's own rule in 6072365157 (refused at error at objectstack validate ⇒ a build-time door exists ⇒ the note stays an acceptance note), the escalation is answered and no card is owed. The PR body as read in this act carries the measured save-door reading, so the round-3 residual about the body is closed. Residue the body names: defineStack and a direct registerFlow were not measured; registerFlow converts first (① item 3), so the same chain runs there — read from code, not measured, and not a reason to withhold. Not escalated further.
  • Residual: two stale runtime test comments. Comments in test files that do not ship; the body names the carrier (the next edit of either file). Answered.

Implemented-by: claude/issue-22343-try-catch-retry-strict
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 02:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 02:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit ac8f2c5 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22343-try-catch-retry-strict branch October 9, 2026 03:27
os-tesla pushed a commit that referenced this pull request Oct 9, 2026
… merging main at e75dced (step 18: 64 conversions, 326 semantic entries)

main added two step-18 semantic entries since b460153:
sys-view-definition-retired (#22374) and
try-catch-and-retry-policy-undeclared-keys-refused (#22380), and #22380
reworded the existing entry flow-builtin-node-config-undeclared-keys-refused.
At protocol 18 both generators project every step-18 entry, so both
documents gain the two entries and carry the reworded text. The conversion
ids are unchanged. registry.ts is current as merged
(check:migration-registry exits 0), so it is not regenerated.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
os-tesla pushed a commit that referenced this pull request Oct 9, 2026
 declare the current protocol, ^18

main added two manifest fixtures that declare engines.protocol '^17' and
stand for a valid current app, not for an old artifact:
- packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts
  (#22365): at protocol 18 the load-seam handshake refuses it, and all four
  cases fail with ProtocolIncompatibleError before reaching their subject.
- packages/cli/test/retry-policy-key-validate-door.test.ts (#22380): os
  validate only advises on the gap, so it stays green either way, but its
  subject is the retry-policy key door, not the protocol's age.
Both now read '^18', like the other current-app fixtures this change moved.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414)

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

Stage 30 of this card: the class (e) remainder, the test strings shipped
under the `packages/spec/src` subdirectories, as ruled in `5902360492`
on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12
files. This stage rewrites 7 of them (6 titles and 1 expect message, 8
ids) in 5 files: each now states what its record decided, or drops the
number where the title already says it. The other 10 messages / 10 ids
stay, 4 because earlier stages decided they are not citations and 6
because an assertion matches the string against text this claim does not
let the stage edit; both groups are named under "What stays". Text only:
no assertion, identifier, test count, code comment, file name or
non-test file changes. No file is deferred.

## Census (re-taken first)

The instrument is stage 28's `census28.cjs`, byte-identical (md5
`31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29
published): an AST walk over the `packages/spec/src` test files, one
message per folded string (a lone literal, a template, or a plus chain)
that matches the gate's id pattern, a title when the folded root is
argument 0 of a describe / it / test / suite / bench call, comments
never read. It was run against the three published readings before it
was trusted, and all three reproduce exactly: 128 messages / 130 ids in
37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76
in 24 files at `b7e01fbbd`.

| reading | messages / ids | files |
|:--|--:|--:|
| base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 |
| stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base |
12 |
| this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 |
| the 5 edited files, this head | 0 / 0 | 0 of 5 |

Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 /
3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1
/ 1 more, all in `ui`: the title `carries no ruling date and no tracker
id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322
(`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So
`ui` reads 10 / 10 in 7 files, and nothing else moved. The census at
`origin/main` `e75dceddd` (8 commits past the base, none touching the 12
files) reads the same 17 / 18 in the same 12 files, so nothing regrew
while this stage ran. The reading is within one message of the claim's,
so there was no re-cut.

Per file, messages at base: `ai/build-progress` 2,
`api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3
ids), `contracts/approval-service` 1, `kernel/manifest` 1,
`ui/action-description` 1, `ui/component-props-unknown-members.pin` 1,
`ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2,
`ui/notification` 1, `ui/strictness-batch14` 1,
`ui/view-submit-redirect-url` 2.

Controls:
- Pathspec: the 12 named paths hit the control word `describe(` in 12 of
12 files and a nonsense word in none; the census scanned 12 of 12.
- Planted, in a scratch tree: an id in a describe title, a plus-chain
title, an expect message, a template literal, a cross-repo spelling and
a ledger-style string each read once (6 / 6); a comment, a six-digit
colour, an HTML entity, a two-digit number and a hex colour with a
letter read 0.
- Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and
2 messages at base and 0 at the head; the 7 untouched files read the
same at both ends.

## Deferral

At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan
before opening this PR (04:13Z), 13. Every file list was read through
REST (605 and 593 rows). None touches any of the 12 files: lit control
`api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four
PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are
open; none of them touches a file in this group. Deferred files: none.

## What changed

7 literals, one line each, in 5 files: +7 / -7. Every file keeps its
line count.
- `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix
goes; the title already says what objectstack-ai#22126 landed, that the read serves
the version token and the 409 carries the current one as data.
- `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside
literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an
assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the
`{token}` dialect in flow value slots; the title already stated both, so
only the two numbers go.
- `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix
goes from the REFUSES title.
- `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`:
the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help
and refusals carry no service-interface name, ruling date or foreign
example id, and both titles already say what their bodies pin.
- `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)`
goes from the title.
- `ui/view-submit-redirect-url.test.ts:118`: the expect message `states
the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an
assertion's failure message, so it was needle-checked first (below) and
is the one declared non-title string.

All seven are "drop a number the title already explains". None needed a
rewrite in new words, because each title already carried the decision.

## What stays, and why

10 messages / 10 ids in 7 files, none edited.

Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at
`ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111',
'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema
under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them
by file and line, and every later stage carried them forward.

Six are strings that an assertion matches against text outside this
stage's edit surface. Moving one at the same strength means editing a
non-test source docblock (and, for the first two, its generated
reference page) or the assertion that matches it. The claim forbids both
and says to stop and report, so none is touched; `open_questions` in the
report carries the decision.
- `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237`
`'objectui#7388 block 2'`: `toContain` over the source text of
`ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which
`content/docs/references/ai/build-progress.mdx` renders.
- `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over
the docblock above `continueRestoredRun` in
`contracts/approval-service.ts` (line 999).
- `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the
tombstone note in `ui/notification.zod.ts:94`; the file's own
`toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note.
- `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over
`ui/notification.zod.ts` and `ui/sharing.zod.ts`.
- `ui/component-props-unknown-members.pin.test.ts:322` `ruling:
'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the
file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage
20's ACCEPT (`5998488373`) kept it for this reason and sent it to the
needles' stage.

Readers of the seven rewritten strings: none. `git grep -F` at HEAD over
the tracked tree outside the 12 files, with the full literal, a
24-character window around each id, and the text on each side of each id
(29 needles over all 17 sites): the only hits are the readers of the
kept strings named above, the lit control (`composeStacks` in
`stack.zod.ts`) hits and the dark control does not. The same needles
searched inside the 12 files, outside each literal's own span: the only
hits are two code comments beside `:322`. The five short needles
(`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the
script's 12-character floor, so their readers were confirmed by direct
`git grep -F` with a dark control.

## Cited records

Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8
comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`,
landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the
card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and
the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the
`{token}` dialect in flow value slots and keeps two spellings (the date
macros and `{$User.*}`) until CEL can write them. The describe's
PRESERVATION test still accepts those two, and the title keeps the
record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the
test body say the same thing the record says.

## Verification

At head `8885dbf1c` (one commit on base `11d119ab1`):
- **Text only.** `textonly28.cjs` (stage 28's, md5
`957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their
heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at
2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared
string (`--declared 118`). Every other string token, identifier, number,
punctuation mark and comment is byte-equal. 16 controls, expectations
written in the script before the first run, 16 / 16 as predicted: an
identifier rename, a numeric literal, a comment edit, an undeclared
expect message, a rewritten title given a new id, an id-free title
edited, one title reverted to base (SAME, 0 changed), a declared label
without `--declared`, a declared line plus another changed string, the
declared line alone (SAME, 2 changed), a title re-split into a plus
chain, a test added, an untouched file (SAME, 0 changed), an id appended
to a rewritten title, a kept needle rewritten, a kept hex colour
rewritten. The two controls that mutate a string beside the declared
line fail at the mutated line, not at 118.
- **Tests, 12 files, base and head.** `--project local --project repo`
with the JSON reporter, 618 tests in 88 suites each side, all passed.
Per-file test count and status sequence identical in 12 / 12. 23 full
names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names
carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3
and 3, the same three `[object Object]` it.each rows at both ends.
- **Full spec unit tier at the head**, under the verify lock: `Test
Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`.
- **Build and typecheck**, under the verify lock: `turbo run build` over
`packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`;
`@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck`
holding 52 files / 246 errors / 135 pinned signatures, the same figures
as stage 29; the 12 files are all in the `tsconfig.test.json` program.
- **Gates.** `dispatch-gates.mjs --commands` at the head derives 79
(stage 29's 77 plus `check:authorable-surface` and
`check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79
derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that
keep their roster in a directory one of the paths is in
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:generated` (all 15 artifacts up
to date) also exit 0.
- **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings.
Population from ESLint's own config: 12 configured, 0 ignored, 0 with a
type-aware parser option, so this diff cannot move the verdict of a file
it does not touch.
- **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in
`packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files;
controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json`
present. The rewritten expect message occurs in 0 files of `dist/`; the
control `Unrecognized key` occurs in 42. Nothing published changes.
- **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of
5, not governed; 14 changed lines.
- **Merge.** `git merge-tree --write-tree` onto `origin/main`
`e75dceddd`: clean.
- **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the
changed files.

Declared narrowing: the 12-file base and head comparison ran outside
`os-verify-lock.sh`, after three queue turns (about 28 minutes) ended
without a grant. It is a 12-file run with two workers; the workspace
build, the typecheck and the full unit tier all ran under the lock. The
gates are `check:*` runs, which do not use the lock.

## Acceptance notes

- **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125,
objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in
files that already existed, one of them to a title objectstack-ai#22322 wrote while
stripping a ruling date from a describe. Test files sit outside
`check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so
the per-stage census is the only instrument. An observation about the
burn-down's denominator, not a class a / b / c finding.
- **Comments are untouched.** Code comments in these files still cite
ids (for example `// ─── assignment (objectstack-ai#14149) ───` at
`builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_

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