Found while implementing #6750 (docs pass over content/docs/automation/flows.mdx's retry-policy prose). Filed unassigned, not fixed in that PR — #6750 is text-only over content/docs/** and this is runtime/spec.
The observation
hook.retryPolicy is a live, enforced retry surface with its own two-key shape, separate from the converged RetryPolicySchema. Its retry count is declared and defaulted in two places that disagree:
- Declared —
packages/spec/src/data/hook.zod.ts:265
maxRetries: z.number().default(3).describe('Maximum retry attempts on failure'),
- Read —
packages/objectql/src/hook-wrappers.ts:311
const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? 0));
That is verbatim the pattern #4247 removed from flow errorHandling, recorded in packages/spec/src/automation/flow.zod.ts:651-656:
retryExecution used to carry errorHandling.maxRetries ?? 3 beside a schema that said .default(0), so "how many times does an under-specified flow retry?" had two answers and the winner depended on whether that flow had been through FlowSchema (0) or hand-built and fed to the engine directly (3). One contract, one number.
Here the two answers are 3 (parsed) and 0 (unparsed), i.e. the same defect with the numbers swapped.
Why this is finding and not a queued defect — what I measured
On the authoring path the default is delivered, so no author hits this today:
hook is a registered metadata type — packages/spec/src/kernel/metadata-type-schemas.ts:89 (hook: HookSchema) — so defineStack({ hooks }), PUT /meta and the Studio form all parse through HookSchema and maxRetries arrives as 3. The ?? 0 never fires on that path.
- The reachable-but-dormant half:
wrapDeclarativeHook is a public export (packages/objectql/src/index.ts:125, packages/objectql/src/core.ts:59), and packages/objectql/src/hook-binder.ts does not itself parse (its own comment at :277 notes HookSchema refuses shapes "at parse time" while "this binder accepts" them). A host handing hand-built hook meta to that export gets 0 where the published contract, the generated reference page and the Studio form all say 3.
So: dormant on every path an author uses, live on the public API. Severity graded at filing time is unreliable in both directions — recording it plainly rather than sitting on it.
Adjacent, same read, deliberately NOT filed as defects
Both are noted so the next reader does not re-derive them:
Not in scope of this finding
- Re-litigating whether
hook.retryPolicy should be the converged RetryPolicySchema. It deliberately is not (packages/spec/src/data/datasource.zod.ts:86 warns authors off renaming keys between them), and that is a contract decision, not a bug.
Dedup searches run before filing
Provenance
Measured on origin/main @ e120a5a1d.
Found while implementing #6750 (docs pass over
content/docs/automation/flows.mdx's retry-policy prose). Filed unassigned, not fixed in that PR — #6750 is text-only overcontent/docs/**and this is runtime/spec.The observation
hook.retryPolicyis a live, enforced retry surface with its own two-key shape, separate from the convergedRetryPolicySchema. Its retry count is declared and defaulted in two places that disagree:packages/spec/src/data/hook.zod.ts:265packages/objectql/src/hook-wrappers.ts:311That is verbatim the pattern #4247 removed from flow
errorHandling, recorded inpackages/spec/src/automation/flow.zod.ts:651-656:Here the two answers are 3 (parsed) and 0 (unparsed), i.e. the same defect with the numbers swapped.
Why this is
findingand not a queued defect — what I measuredOn the authoring path the default is delivered, so no author hits this today:
hookis a registered metadata type —packages/spec/src/kernel/metadata-type-schemas.ts:89(hook: HookSchema) — sodefineStack({ hooks }),PUT /metaand the Studio form all parse throughHookSchemaandmaxRetriesarrives as3. The?? 0never fires on that path.wrapDeclarativeHookis a public export (packages/objectql/src/index.ts:125,packages/objectql/src/core.ts:59), andpackages/objectql/src/hook-binder.tsdoes not itself parse (its own comment at:277notesHookSchemarefuses shapes "at parse time" while "this binder accepts" them). A host handing hand-built hook meta to that export gets0where the published contract, the generated reference page and the Studio form all say3.So: dormant on every path an author uses, live on the public API. Severity graded at filing time is unreliable in both directions — recording it plainly rather than sitting on it.
Adjacent, same read, deliberately NOT filed as defects
Both are noted so the next reader does not re-derive them:
retryBackoffMs * attemptatpackages/objectql/src/hook-wrappers.ts:351. That matches the declared shape exactly (nobackoffMultiplier/maxRetryDelayMs/jitteronhook.retryPolicy), so it is a smaller contract, not a drifted one.packages/spec/src/shared/retry-policy.test.ts:174comments that "Adding a fifth retry surface without wiringretryPolicyShape()fails here" (and:189the same for a fourth). The block'ssurfaces()list is hand-enumerated, so a new surface fails it only once someone adds it to the list. The comment overstates the guarantee; the assertions themselves are correct. Cosmetic, spec-prose, andpackages/specsource prose is [finding][spec] #6414 retired the L2 ETL layer, butretry-policy.zod.tsstill teachesETLPipeline.retryas a live surface in six places — including theretryDelayMstombstone an upgrading author actually reads #6630's lane.Not in scope of this finding
hook.retryPolicyshould be the convergedRetryPolicySchema. It deliberately is not (packages/spec/src/data/datasource.zod.ts:86warns authors off renaming keys between them), and that is a contract decision, not a bug.Dedup searches run before filing
is:open hook retryPolicy maxRetries default— 0 hits.is:open hook-wrappers retry backoff— 0 hits.is:open retry policy convergence hook— 1 hit, PM seat Routine [PM seat] triage (objectstack-wide) — 🟢 Routine #6015, unrelated.is:open "two answers" default engine fallback— 1 hit, [PM seat] triage (objectstack-wide) — 🟢 Routine #6015 again.Provenance
Measured on
origin/main@e120a5a1d.