Skip to content

feat(spec)!: a job retryPolicy with no attempts is refused at maxRetries; retry-policy-converged leaves the load path (F2 of #23543) - #23810

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23543-f2-retry-policy-refused
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23543-f2-retry-policy-refused

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #23543
Clause-②: yes (narrowing)

Regen-provenance: since the reviewed head 0bc4f42, dce2266 rewords the D3 entry's reason (prose only, one file, at the seat's request) and 2741849 merges origin/main at f846cc2 through os-regen-merge.sh with check:generated clean; every other file's change equals the reviewed head's.

This PR lands family F2 of ruling 6182001882 (item 2, the loud form) only. F4 and F5-http stay on #23543, one PR each, in the ruled order; #23543 remains open.

What

Ruling F2 A: "a job.retryPolicy block whose maxRetries is omitted or 0 is refused, in the shape flow.errorHandling already uses for strategy: 'retry' … A job with no block stays a single attempt; backoffMultiplier keeps its declared default and is not refused. retry-policy-converged then sets retiredFromLoadPath; the 17 materialization lives on in the D3 chain."

  • The refusal lives on the job, not on the shared schema. RetryPolicySchema (shared/retry-policy.zod.ts) is also a try_catch node's retry region, which the ruling does not cover. So JobSchema gains one root-level refine, job.retryPolicy === undefined || job.retryPolicy.maxRetries >= 1, reporting a custom issue at retryPolicy.maxRetries with the ruling's prescription as its first sentence: "A retry block with no attempts is no retry block: state maxRetries ≥ 1, for example 3, or delete the block." It then names why (the loader used to write maxRetries: 3; a retry re-runs whatever the failed attempt did) and ends with the house os migrate meta --from 16 sentence. At defineStack this is STACK_SCHEMA_INVALID / 422 at jobs.N.retryPolicy.maxRetries.
  • Omitted and 0 are one case by construction. The refine reads the parsed policy, where the default has already filled an omitted count with 0. No pre-default hook is needed.
  • No new dropped-refinement site. The refine sits on the job's root, a site dropped-refinements.baseline.json already records for system/Job (the pull exclusivity refine). A field-level refine would have added a ledger site to system/Job and to every schema that embeds a job.
  • The retryPolicy describe no longer says "declaring it without maxRetries also means no retry"; it says a block must state maxRetries >= 1. content/docs/references/system/job.mdx is regenerated from it.
  • The D2 entry. retry-policy-converged gets retiredFromLoadPath: true and retiredAfter: '17.7.0', the stamp F1, F5-jsx and F3 used. Its "recorded verdict at 18" paragraph is rewritten as the retirement record. The fixture is unchanged: every includeRetired seam still replays the whole entry.
  • The D3 entry. 18.job-retry-policy-without-attempts-refused (semantic, step 18) with conversionIds: ['retry-policy-converged'], already a step-17 member. It states the decision no conversion makes for the author: keep the written count, or delete the block. It also covers the one silent change the ruling accepts: a block that states maxRetries and omits backoffMultiplier now gets 1 where the loader wrote 2.
  • The verdict-list pin in conversions.test.ts moves the entry to the retired list. The kept list now holds three entries: flow-node-http-callout-rename, page-component-visibility-to-visibleWhen and view-visibleOn-to-visibleWhen. The removal-version pin's JOIN gains one retry-policy-converged row: the job refusal, which names @objectstack/spec 18 and --from 16.

Mechanism assumptions, measured

  1. Who else parses RetryPolicySchema. It holds. Its parsers are job.retryPolicy (system/job.zod.ts) and a try_catch node's retry (automation/control-flow.zod.ts). flow.errorHandling builds from retryPolicyShape(), and worker.zod.ts has its own TaskRetryPolicySchema. So the refusal is on JobSchema. A control pins that RetryPolicySchema.safeParse({ backoffMs: 5000 }) still succeeds.
  2. Omitted versus 0. It holds: judging the parsed value is enough (above). Both spellings are pinned at the key.
  3. The entry and the pins. It holds as described. The kept list is 3. The JOIN row is owed, because retiredAfter 17.7.0 is the newest published stable release.
  4. The retryDelayMs half. It needs nothing beyond the retirement. Both surfaces carry the retryDelayMs tombstone, and a new case in conversions.test.ts pins an authored flow-level retryDelayMs refused at defineStack naming backoffMs. Two existing tests relied on the load converting it, and both were re-pointed:
    • flow-builtin-node-config-keys.test.ts "CONTROL: a pre-17 retryDelayMs alone is converted first where the door converts" now reads the replaying seam (applyConversionsToFlow(…, { includeRetired: true }), which parses) and the authoring door (defineStack, refused at config.retry.retryDelayMs naming backoffMs).
    • The cli retry-policy-key-validate-door.test.ts "declared" fixture now writes backoffMs.
  5. The four load-path suites (F3's record 6205630082). The measurement that decides them: os migrate meta replays from MIGRATION_SUPPORT_FLOOR 16, so --from 16 replays steps 17 and 18, and after F2 every entry of both is retired. The only entries still live on the load path are toMajor 11 and 15 (F4's two, F5-http's one), and the chain never replays those. So no live entry can be both converted by the load and listed by the chain. Per suite:
    • packages/spec/src/stack-authored-input.test.ts gets a synthetic probe. Its subject is the load pass itself, not an entry, so a vi.mock of ./conversions/registry appends a test-owned live conversion (stack-authored-input-probe, the old retryDelayMs rename). Every assertion is unchanged and now reads the probe's id. No later family has to re-point this suite again.
    • packages/cli/test/migrate-meta-load-conversions.test.ts is partly retired and partly re-pointed. A probe cannot reach it: the temp project loads the built @objectstack/spec through a symlinked node_modules, outside vitest's module graph.
      • Retired: "lists a conversion the load already applied …" (the defect's chain reading), which has no subject on this tree. Its write / re-run / clean-reload half is pinned at the same layer by the two-refused-spellings case, as the file header records.
      • Re-pointed: "the authored-source load reads the argument off the default export" and "defined twice starts from the innermost argument". They now use a discriminator that needs no conversion: a strict defineStack fills the declared defaults (backoffMultiplier: 1, maxRetryDelayMs: 30000, jitter: false), and the authored argument does not have them. I measured this on the built dist before writing it.
      • The old defined-twice chain case moved to the refused arm, where retryDelayMs now lives. The mixed case was retitled "two refused spellings the chain converts".
    • packages/cli/test/migrate-meta-composed-load-conversions.test.ts gets the same treatment, for the package body.
    • packages/cli/test/migrate-meta-composed.test.ts (its load-path row) is kept and renamed. Its assertions still hold, but its name was false. It is now "a refused stack carrying a second refused spelling".
  6. Authored sources. I swept examples/**, packages/**, content/docs/** and skills/** for every object-literal retryPolicy block, single- and multi-line, plus non-literal values. No job block omits maxRetries or writes 0. The hits are hook.retryPolicy (a separate declaration, outside the ruling) and this PR's own fixtures. One test fixture wrote maxRetries: 0 on a job (job.test.ts, "one-time scheduled job"). It means a single attempt, so the block was deleted. skills/** has nothing to report.
  7. Serial neighbours. PR feat(spec)!: retire page:header showStar — the follow star the reference console no longer draws #23781 and PR feat(spec)!: delete checkPageComponentViewData; the component-props rule is the one judge of a page component's retired api data binding #23784 had not landed. origin/main moved 5 commits (none in this PR's files) and was merged through scripts/pm/os-regen-merge.sh with no conflict. check:generated then read "All 14 generated artifacts are up to date".
  8. The metadata save door (protocol.ts) is untouched. For a non-flow type it judges the body as stored, with no conversion replay, so a job body meets this refusal there. job has not been runtime-creatable since 17, though, so no door claim is made for it. The flow half (retryDelayMs) meets the door's flow fallback exactly as before, because that fallback already replayed this entry while it was live. The seat's separate save-door decision covers it with the other retired flow entries.

Readers updated (prose made false by the retirement)

  • content/docs/automation/jobs.mdx: "declaring retryPolicy without stating a count still means no retry" is now the refusal rule.
  • packages/services/service-job/src/run-with-policy.ts (comment only): the default is reached only by a hand-built caller.
  • shared/retry-policy.zod.ts (two comments), liveness/flow.json (one note) and the job-timeout-to-timeout-ms docblock in registry.ts no longer call the entry a load-path rewrite.

Changeset

.changeset/23543-f2-job-retry-policy-without-attempts-refused.md:

  • Bumps: @objectstack/spec minor; @objectstack/service-job patch (comment-only).
  • It is BREAKING and carries Clause-②: yes (narrowing). It gives FROM → TO: retryPolicy: {} → retryPolicy: { maxRetries: 3 } or delete the block, and a block with maxRetries: 0 → delete it. It has a "What an author who still writes it sees" paragraph, the backoffMultiplier 2 → 1 note, the os migrate meta --from 16 path, and how stored rows keep loading.
  • ADR-0087 disposition: registered job-retry-policy-without-attempts-refused.

Verification

All readings are on head 0bc4f428 (the merge of origin/main 46ca793e) unless noted.

  • @objectstack/spec: full test 683 files / 20888 passed (1 todo), exit 0. typecheck exit 0 (pre-merge 9e09e0dd; the merge brought no change to this PR's files). build + check:generated: "All 14 generated artifacts are up to date".
  • @objectstack/cli: the full unit tier passed, 297 files. The first run had 295 passing, plus the 2 build-dependent published-subpath pins, which failed only with "packages/cli is not built" and passed 2 / 29 after pnpm --filter @objectstack/cli build. 4348 tests passed, 29 skipped. Integration tier: the one edited file, retry-policy-key-validate-door.test.ts, 2 / 2. typecheck exit 0, after building the closure with declarations.
  • @objectstack/service-job: test 11 files / 117 passed. typecheck exit 0.
  • Gates. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 114 commands on 0bc4f428. Each ran one by one, with its exit code captured before any pipe. --ran reports "114 derived famil(ies) accounted for — 111 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)". The three are the prerequisite refusals F3 also recorded: check-plugin-teardown-shape --self-test (shallow clone), check:skill-examples (client dist absent) and check:dual-build-cjs-loads (full build absent). Notable greens: check-adr-0087-registration --base origin/main, check-changeset-no-major, check-empty-changeset, check:dts-closure, check:nul-bytes, check:liveness, check:spec-changes, check:upgrade-guide.
  • Ablation, the refusal (scripts/ablation-replace.mjs, on 9e09e0dd). job.retryPolicy.maxRetries >= 1 was replaced by >= 0.
    • On-disk proof: anchor 1 → 0, blob 28e227b31f4e → c6c4d7684caa.
    • Result: 7 tests went red across job.test.ts (3), conversions.test.ts (3, including the stored-row case's "the row as written is refused" leg) and retired-key-removal-version.pin.test.ts (the retry-policy-converged JOIN row). Direction: red.
    • Restore proven: blob == HEAD and git diff HEAD empty. Spec tests import src, so there is no dist leg.

Acceptance notes

  • Labels: this dispatch's write budget names no label-write, so this PR carries none from me.
  • The refusal is a root-level refine, so zod reports it only once the job's other fields pass. A job with another error and an empty retry block shows that error first and this one after it is fixed. A field-level refine would report both at once, at the cost of new dropped-refinement ledger sites in every schema that embeds a job.
  • tsc does not see the omission: maxRetries stays optional in RetryPolicy, because the shared input type also serves try_catch. The parse-time prescription is the channel for this family.

Generated by Claude Code

…licy-converged from the load path (F2 of #23543)

Claude-Session: https://claude.ai/code/session_01MT2aGLQQhH8ZsPrgqHMrwz
Co-authored-by: Claude <noreply@anthropic.com>
…empts is refused

Claude-Session: https://claude.ai/code/session_01MT2aGLQQhH8ZsPrgqHMrwz
Co-authored-by: Claude <noreply@anthropic.com>
…am and the authoring refusal

Claude-Session: https://claude.ai/code/session_01MT2aGLQQhH8ZsPrgqHMrwz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-job, @objectstack/spec, touching 9 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-job/src/run-with-policy.ts, packages/spec/liveness/flow.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/error-handling-client.mdx (via maxRetries (literal, a string literal on a changed line))
  • content/docs/automation/flows.mdx (via maxRetries (literal, a string literal on a changed line), retryPolicy (literal, a string literal on a changed line))
  • content/docs/automation/index.mdx (via retryPolicy (literal, a string literal on a changed line))
  • content/docs/automation/jobs.mdx (via maxRetries (literal, a string literal on a changed line), retryPolicy (literal, a string literal on a changed line))
  • content/docs/concepts/metadata-lifecycle.mdx (via JobSchema (symbol, a top-level const), retryPolicy (literal, a string literal on a changed line))
  • content/docs/getting-started/quick-reference.mdx (via JobSchema (symbol, a top-level const))
  • content/docs/protocol/kernel/error-handling.mdx (via maxRetries (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/index.mdx (via maxRetries (literal, a string literal on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via maxRetries (literal, a string literal on a changed line), retryPolicy (literal, a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object retryPolicyConverged), retiredFromLoadPath (symbol, a field of const object retryPolicyConverged))
  • content/docs/releases/v17/17-7.mdx (via JobSchema (symbol, a top-level const))

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
  • 2 changed file(s) yielded no anchor (packages/services/service-job/src/run-with-policy.ts, packages/spec/liveness/flow.json) — 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 — 142 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 f846cc20202e580fa477e01a9cf11adac106d098 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · PR #23810 @ 0bc4f4286a (#23543, family F2) · domain:spec seat 1 · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9 · 2026-10-16T04:02Z

Reviewer of record. Every item below was checked against GitHub, not against the report (6207784735 on #23543).

  • Shape:
    • Draft, base main. The first line is Part of #23543 and no closing keyword appears anywhere, so the card stays open for F4 and F5-http. Then Clause-②: yes (narrowing), matching claim 6206691402.
    • 18 files, +517/−202. Assignee os-elon-musk.
  • Ruling 6182001882 item 2, read in the diff:
    • JobSchema gains one root refine: job.retryPolicy === undefined || job.retryPolicy.maxRetries >= 1, a custom issue at retryPolicy.maxRetries. The message opens with the ruling's prescription verbatim. The parsed default fills an omitted count with 0, so "omitted" and "0" are one case, as the ruling asks. The ablation (>= 1 → >= 0) reds 7, and the restore is byte-identical.
    • The shared RetryPolicySchema is unchanged, so a try_catch node's retry region is judged as before, pinned by a control. A job with no block stays a single attempt, and backoffMultiplier keeps its default and is not refused.
    • retry-policy-converged sets retiredFromLoadPath with retiredAfter: '17.7.0'. The verdict pin keeps 3 entries, flow-node-http-callout-rename and the two visibility entries. The removal-version JOIN gains its row.
    • The D3 entry job-retry-policy-without-attempts-refused carries conversionIds: ['retry-policy-converged'] and names the decision left to the author: keep the written count, or delete the block.
    • The job describe at :366 no longer says an omitted count "also means no retry". It says the block is refused.
  • Item 7: one BREAKING changeset, @objectstack/spec minor (@objectstack/service-job patch, comment-only, the F3 precedent), with FROM → TO, "what an author who still writes it sees", and the adr-0087 marker.
  • The F3 carry-forward, the four load-path suites: decided one by one, each with its reason.
    • stack-authored-input gets a synthetic probe, a test-owned live rename appended to a mocked registry.
    • The two migrate-meta-*-load-conversions suites retire their accepted-arm case, which has no subject left. Their other cases are re-pointed to the declared-default fill, which needs no conversion.
    • migrate-meta-composed is renamed only.
  • The report's open questions, the seat's calls:
    • The suites: A, the only option with a live subject on this tree, and it survives F4 and F5-http.
    • The refusal's place: A, the root refine, which is the ruling's named shape with zero dropped-refinement ledger growth. The diagnostic-order note (the issue appears once the job's other fields parse) is an Acceptance note, not a defect.
    • The save door: no action here, as claimed. A job body meets this refusal at the door because it is judged as stored. The flow fallback is unchanged. The door stays the seat's separate decision.
    • Stored job rows: A. Rehydration still replays the conversion, so a row stored without a count keeps its three retries, which was already the 17 behaviour ("the 17 materialization lives on in the D3 chain").
  • Outside the claimed file surface, accepted: each one was measured, and each is named in the PR body.
    • retry-policy-key-validate-door.test.ts: a fixture that only the conversion made valid.
    • flow-builtin-node-config-keys.test.ts: a CONTROL that pinned the conversion.
    • job.test.ts: a fixture writing maxRetries: 0.
    • The prose the retirement made false: content/docs/automation/jobs.mdx, the retry-policy.zod.ts comments, one liveness/flow.json note, and a registry.ts docblock.
    • The regenerated references/system/job.mdx.
  • Merge from main: 46ca793e came in through os-regen-merge.sh with no conflict, and check:generated was up to date afterwards.
  • CI on this head at this act: 16 success, 5 skipped, 17 in progress. Not yet read as green.
  • Before the queue: a CONTRACT_REVIEW_TIER contract review (face packages/spec/src/**, Clause-②: yes (narrowing)), and every check green on the head.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0bc4f4286adb045677609ddddc921c184420e025
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts, packages/spec/src/shared/retry-policy.zod.ts, packages/spec/src/system/job.zod.ts + packages/spec/src/automation/flow.zod.ts, packages/spec/src/automation/control-flow.zod.ts, packages/spec/src/conversions/apply.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/conversions/conversions.test.ts, packages/spec/src/conversions/retired-after.census.test.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/src/shared/retired-key-migrate-sentence.test.ts, packages/spec/src/migrations/types.ts, packages/spec/src/migrations/migrations.test.ts, packages/spec/src/migrations/registry.ts.template, packages/spec/dropped-refinements.baseline.json, packages/spec/src/system/job.test.ts, packages/spec/src/stack-authored-input.test.ts, packages/spec/src/automation/flow-builtin-node-config-keys.test.ts, packages/cli/src/commands/migrate/meta.ts, packages/cli/test/migrate-meta-load-conversions.test.ts, packages/cli/test/migrate-meta-composed-load-conversions.test.ts, packages/cli/test/migrate-meta-composed.test.ts, packages/cli/test/retry-policy-key-validate-door.test.ts, packages/metadata-protocol/src/protocol.ts, packages/objectql/src/hook-wrappers.ts, .changeset/23543-f2-job-retry-policy-without-attempts-refused.md

Inputs: card #23543 (body, ruling 6182001882 items 2 and 7, seat delta 6181021557, presentation 6178791899 F2 section, F3 landing record 6205630082, F2 claim 6206691402, dev report 6207784735, ACCEPT 6207806645), PR #23810 (body, 18-file list, net diff against merge-base 46ca793edbef645731bebadb1bd7a63564cafba2), and the check-runs on the head. Every code claim below was read at the literal head sha. The lane seat's ACCEPT at TIER_DEFAULT covers the fourteen files outside hits as a standalone diff; this record widens into the ones the four hits hunks make claims about.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged against the ruling's letter.

  1. JobSchema narrows: a retryPolicy block whose parsed maxRetries is below 1 is refused. RIGHT. The refine (job.zod.ts, root .refine after the pull exclusivity refine) reports a custom issue at ['retryPolicy', 'maxRetries'] over the PARSED policy, so omitted (the shared default fills 0) and written 0 are one case by construction; maxRetries is z.number().int().min(0).max(10).default(0) on the shared shape, so the comparison is always over an integer. This is the shape ruling item 2 names: flow.errorHandling's superRefine at flow.zod.ts:1284 is a custom issue at maxRetries judging the parsed value below 1 in both spellings. The message opens with the ruling's prescription verbatim ("A retry block with no attempts is no retry block: state maxRetries ≥ 1, for example 3, or delete the block."). A job with no block stays a single attempt (CONTROL pinned in job.test.ts and conversions.test.ts); an omitted backoffMultiplier is not refused and keeps its declared default 1 (CONTROL pinned). At defineStack the refusal is STACK_SCHEMA_INVALID / 422 at jobs.N.retryPolicy.maxRetries, pinned as the ONE issue on an otherwise complete job.

  2. The refusal sits on the job, not on RetryPolicySchema. RIGHT. The shared schema's only other parser is a try_catch node's config.retry (control-flow.zod.ts:343), which the ruling does not cover; flow.errorHandling builds from retryPolicyShape() and carries its own rule; hook.retryPolicy (hook.zod.ts:387) and the worker's TaskRetryPolicySchema are separate declarations. A control pins RetryPolicySchema.safeParse({ backoffMs: 5000 }) still succeeding. The root site is the right ledger choice: dropped-refinements.baseline.json already records system/Job sites "" and schedule.options[0].timezone, so the new refine adds no site; the baseline is untouched and the repo gates are green. The trade the dev names (zod skips object refines on an aborted parse, so this issue appears only once the job's other fields parse) is a diagnostic-order note, not a defect; it is the same order flow.errorHandling has. Prime Directive 10 is met by noting it in the acceptance notes rather than filing.

  3. The parse-time prescription and its os migrate meta sentence are right for the conversion this refusal stands in for. RIGHT. The conversion is retry-policy-converged, toMajor: 17: the step the chain replays for an author whose source was written against 16. The house rule (packages/cli/src/commands/migrate/meta.ts docblock) sets the sentence's N to one below that conversion's toMajor, so --from 16 is the correct spelling, as the two F3 lifts (also step-17 members) spell it. MIGRATION_SUPPORT_FLOOR is 16 and MIGRATIONS_BY_MAJOR holds steps 17 and 18, so --from 16 composes the chain's whole range and does replay the entry. The sentence is the house sentence by letter and lives in a plain-quoted literal under packages/spec/src, so the migrate-sentence pin judges it. It is true of the tool for this refusal: the mechanical edit is the omitted-count materialization (maxRetries: 3 plus backoffMultiplier: 2), listed and provable; a block that WRITES 0 is not touched by the conversion (it fills only an undefined count), which is "the rest you apply by hand", and the prescription's own first sentence says to delete it. The removal-version pin asserts exactly this arithmetic: every --from N in the text equals toMajor − 1, and every release named equals the major retiredAfter derives, which under the 18.0.0-next.2 label at newest stable 17.7.0 is PROTOCOL_MAJOR 18. The message names @objectstack/spec 18 once and --from 16 once, so the JOIN row the pin demands for an entry stamped at the newest stable is satisfied by the message as written.

  4. retry-policy-converged leaves the load path: retiredFromLoadPath: true, retiredAfter: '17.7.0'. RIGHT. The stamp follows the census rule (an unpublished retirement under a prerelease label takes the newest published stable below the label's release triple) and equals the F1, F5-jsx and F3 stamps. The fixture is unchanged and stays disjoint from the three entries still live on the load path (flow-node-http-callout-rename at toMajor 11 renames http_* node types; the two visibility entries at 15 touch pages and views; the fixture holds start and try_catch nodes and jobs only), so the retired-fixture zero-notice contract holds. The retirement's second accept-set consequence is named and pinned: an authored flow-level or try_catch retryDelayMs, which the load used to rename before the parse, now meets its tombstone at defineStack naming backoffMs (conversions.test.ts new case; flow-builtin-node-config-keys.test.ts CONTROL re-pointed to the replaying seam applyConversionsToFlow(…, { includeRetired: true }) plus the refused authoring door; the CLI validate-door "declared" fixture now writes backoffMs). That consequence is the ruling's own ("retry-policy-converged then sets retiredFromLoadPath"), and the D2 window text in ADR-0087 is what closes it.

  5. Stored rows keep the numbers they ran with. RIGHT. applyConversionsToStoredItem (conversions/stored.ts:107) forces includeRetired: true, and the new test pins a stored job row with no count: refused as written by JobSchema, loading at the seam with maxRetries: 3, backoffMultiplier: 2. The protocol.ts read path (convertStoredItem) funnels every non-flow row through that seam, and the automation engine's flow rehydration replays with the same full-chain policy.

  6. The D3 entry 18.job-retry-policy-without-attempts-refused. RIGHT in mechanism. conversionIds: ['retry-policy-converged'] names a step-17 member from a step-18 entry, which SemanticMigration.conversionIds allows ("its own step or an earlier one") and migrations.test.ts:80 enforces; the entry does judge that conversion's applied edit (keep the written count or delete the block). reason and acceptanceCriteria are non-empty; surface carries no backticks or pipes for the upgrade-guide renderer; relevantWhen is optional and omitted, as the F1, F5-jsx and F3 entries omit it. No STEP18_RATIONALE fragment is added, which matches those three precedents and the gate population (121 fragments against 386 step-18 entries; the fragment list is not a per-entry obligation). One prose imprecision, not load-bearing: the reason says objectstack migrate meta "lists every such block" after defining "such block" as one that omits the count "(or writes 0)"; for the written-0 spelling the conversion lists nothing, since it fills only an undefined count. The refusal's own prescription and the changeset's second migration bullet both say to delete that block, so an upgrading reader is not sent the wrong way. Noted in ③ for the lane seat.

  7. Verdict-list pin and removal-version JOIN. RIGHT. conversions.test.ts moves retry-policy-converged to the retired list with retiredAfter: '17.7.0'; the kept-without-ruling list reads exactly the three entries F4 and F5-http own (flow-node-http-callout-rename, page-component-visibility-to-visibleWhen, view-visibleOn-to-visibleWhen), one fewer than F3's record left it. The JOIN gains one retry-policy-converged row rendering the job refusal at retryPolicy, owed because the pin refuses any entry stamped at the newest stable without a row.

  8. Public surface. RIGHT. JOB_RETRY_POLICY_NO_ATTEMPTS is module-private; the RetryPolicy input type is unchanged, so tsc does not see the omission. That is the ruling's own shape: flow.errorHandling's rule, which item 2 names, is also parse-only, and the ruling's item 7 channel ("tsc and the parse-time prescription") is served by the prescription at every parsing door. Option C in the report (a job-only policy schema with maxRetries required) was rightly not taken: it would have been a second declaration of the converged policy. The retryPolicy describe changed and content/docs/references/system/job.mdx is regenerated from it; no authorable-surface or manifest baseline moves, consistent with a root refine and the green check:generated.

  9. The four load-path suite decisions (report 6207784735, open question 1). RIGHT, and the premise behind them holds on the tree: at the head the only entries without retiredFromLoadPath are flow-node-http-callout-rename (11), page-component-visibility-to-visibleWhen (15) and view-visibleOn-to-visibleWhen (15); the chain carries steps 17 and 18 from floor 16; so no live entry can be both converted by the load and listed by --from 16. Per suite:

    • packages/spec/src/stack-authored-input.test.ts: a synthetic probe (vi.mock('./conversions/registry') appending a file-owned live retryDelayMs rename, toMajor 17) is the right instrument. The subject is the load pass itself, the mock is file-scoped, no production injection point is added, every assertion is unchanged and now reads the probe's id, and F4 and F5-http cannot orphan it.
    • packages/cli/test/migrate-meta-load-conversions.test.ts: retiring "lists a conversion the load already applied" is right, since the defect's chain reading has no subject; its write / re-run / clean-reload half survives in the two-refused-spellings case at the same layer. The two argument-reading cases are re-pointed to a discriminator that needs no conversion: a strict defineStack fills the declared defaults (backoffMultiplier: 1, maxRetryDelayMs: 30000, jitter: false, which are the shared shape's defaults), and the authored argument carries only what the author wrote; the default-export and named-export assertions are kept. The defined-twice chain case moves to the refused arm where retryDelayMs now lives. A probe cannot reach this suite: it drives a CLI subprocess over a temp project that resolves the built @objectstack/spec outside vitest's module graph, which is visible in the suite's writeProject / runMeta design.
    • packages/cli/test/migrate-meta-composed-load-conversions.test.ts: the same four pins for the package body, by the same reasoning.
    • packages/cli/test/migrate-meta-composed.test.ts: kept and renamed to "a refused stack carrying a second refused spelling"; its assertions (RETRY_SITE plus TIME_SITE applied, two written sites) still hold, because the chain's step 17 still renames a refused stack's retryDelayMs when the raw argument is handed through.
      Option B (re-point to the toMajor 11 / 15 entries) is rightly refused: those steps are below the floor and never replay.
  10. Authored sources. RIGHT. A sweep of every retryPolicy block at the head under examples/**, packages/** (non-test), content/docs/** and skills/** finds no job block that omits maxRetries or writes 0: the five hits are four generated i18n labels for the hook form and HookSchema's own block in packages/objectql/src/hook-wrappers.ts:361, a separate declaration outside the ruling. The job.test.ts "one-time scheduled job" fixture that wrote maxRetries: 0 now has no block, which is the ruling's single-attempt spelling.

  11. The metadata save door is untouched, correctly. RIGHT. protocol.ts judges a non-flow body verbatim (schema.safeParse(request.item) through getMetadataTypeSchema), with no conversion replay, so a job body carrying an empty retry block meets this refusal loud at the door, the "no double spelling" direction; only the flow fallback (flowGateVerdictBody) replays D2, and that is the seat's own open decision from F3's record, which this PR neither widens nor narrows. The report's "job has not been runtime-creatable since 17" sentence was not traced; it is not load-bearing, since the door answers in the ruled direction either way.

  12. Readers made false by the retirement (jobs.mdx, run-with-policy.ts comment, two retry-policy.zod.ts comments, one liveness/flow.json note with status and verifiedAt unchanged, the job-timeout-to-timeout-ms docblock). RIGHT; Spec property liveness is green.

② Semver level

.changeset/23543-f2-job-retry-policy-without-attempts-refused.md: @objectstack/spec minor (the house rule forbids major in the launch window; check-changeset-no-major is in the green Check Changeset), @objectstack/service-job patch for a comment-only edit (harmless, and check-empty-changeset passed). The body carries Clause-②: yes (narrowing), matching the PR body and ruling item 7, which makes it BREAKING with at least minor; the BREAKING banner states the FROM → TO mapping for both spellings (retryPolicy: {} or { backoffMs: 5000 } → { maxRetries: 3, backoffMs: 5000 } or delete; maxRetries: 0 → delete) and the one-line fix, the "what an author who still writes it sees" paragraph, the backoffMultiplier 2 → 1 note, the house --from 16 sentence, and how stored rows keep loading. The flow half's user-visible change (an authored retryDelayMs is now refused where the load used to rename it) is named with its rename. Exactly one ADR-0087 marker, registered job-retry-policy-without-attempts-refused, whose id resolves in the registries at HEAD (check-adr-0087-registration --base origin/main green). @objectstack/cli changes are test-only and content/docs/** publishes no package, so no further changeset is owed. The diff publishes exactly what the changeset says. Clause-②: yes (narrowing) is the correct line.

③ Boundary flags

Dev flags (report deviations): the file surface widened by four measured items, each named in the PR body and folded into the claim by ACCEPT 6207806645: answered, right (each is a reader or fixture the retirement made false). No label-write and no PR subscription: as the dispatch's write budget says. The interim CLI typecheck red was an OS_SKIP_DTS closure, re-run green with declarations; the head's four Type Check jobs are green: answered.

open_questions:

  1. Four load-path suites, A taken: answered in ① item 9; right, and the premise is verified on the tree.
  2. Refine site, A taken (root refine): answered in ① item 2; right. The diagnostic-order trade is a note, not a defect, and belongs in the acceptance notes where it is.
  3. Save door, no action: answered in ① item 11; right. The seat's standing door decision (F3 record carry-forward) is unaffected.
  4. Stored job rows saved at 17 without a count read as three retries through the stored seam's replay: A (nothing) is right for this family. The seam replayed the live entry at 17 exactly as it replays the retired one now, so F2 changes no stored row's behaviour, and ruling item 2 says the 17 materialization lives on in the D3 chain. Noted, not filed, which is the right carrier.

out_of_scope_findings: none filed; the diagnostic-order note is in the acceptance notes. Correct.

Escalated: nothing blocking. Two notes for the lane seat, neither a gate failure:

  • The D3 entry's reason says objectstack migrate meta lists every block that omits the count "or writes 0"; the conversion lists only the omitted-count case. A one-clause wording fix ("lists every block that omits the count; a block that writes 0 you delete by hand") can ride any later touch of that entry. The parse-time prescription and the changeset are already exact.
  • At review time, Build Core, all four Type Check jobs and Test Core shards 1 and 6 are green, no check has failed, and Test Core shards 2 to 5 are still running. The ACCEPT already conditions the queue on an all-green head; this record passes the diff and the card, and the queue waits for those four conclusions as the seat's ACCEPT says.

Gates read on the head: Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates and every Lint · job, Spec property liveness, Build Core, Build Docs, Check PR Size, No other open PR may claim the same issue, Part-of PR must not also close its card, The card this PR closes must claim this branch, Type Check · consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, Test Core (1/6), Test Core (6/6): success. Test Core (2/6) to (5/6): in progress. Console Pin Gate, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke: skipped by path, as expected for this diff.

Implemented-by: claude/issue-23543-f2-retry-policy-refused
Reviewed-by: session_01HjR8RUwTPQ3TgbfKihmxp9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 05:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 05:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit f606ba2 Oct 16, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23543-f2-retry-policy-refused branch October 16, 2026 05:58
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 protocol:system size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants