Skip to content

feat(formula,spec): stable refusal codes and params beside every expression refusal message - #20352

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20291-expression-refusal-codes
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20291-expression-refusal-codes

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20291

Clause-②: yes

Rework round 1

Order of record: the seat's comment 5861789336 on #20291. Its instruction is to fold in the file's second refusal producer, structuralConditionRefusal, which round 0 reported as a same-family finding. Claim 5860074154 is unchanged, and the file is already in its surface. The round adds two commits on top of 2a6dee312c (658a8637f5, 5581ed26ee); no rebase, amend or force-push.

  • structuralConditionRefusal now returns StructuralConditionRefusal: its previous { message, source } plus code: 'structural-condition-shape' and params: { found }. found is one of array, ast-without-source, object-without-source, number, boolean, bigint, symbol and function. The message is byte-identical, STRUCTURAL_CONDITION_SHAPE_REFUSAL included.
  • The spec code set is now one closed table for both of the file's producers, the way the formula set is laid out. FlowSlotRefusalParams and the frozen FLOW_SLOT_REFUSAL_CODES (4 codes) replace round 0's PredicateSlotRefusalParams and PREDICATE_SLOT_REFUSAL_CODES. Per-producer code types are PredicateSlotRefusalCode and StructuralConditionRefusalCode. The two replaced names never reached main.
  • Census confirmed: the file has exactly two exported refusal producers, predicateSlotRefusal and structuralConditionRefusal. Its other exported functions are isExpressionEnvelopeShaped (a boolean guard), resolveFlowNodeExpressions and resolveFlowNodeValueSlots (resolvers returning { entry, path, value }). The only message: sites in the file sit inside those two producers.
  • The byte-identity corpus now has 64 cases: round 0's 43 plus 21 for structuralConditionRefusal (6 admitted, 15 refused, every found kind). base is origin/main de091b50e6 for the 43; for the 21 it is 2a6dee312c, where that function was still untouched. The result is 0 diffs.
  • The pin file became flow-slot-refusal-codes.test.ts. It has one pin per code (4 codes; structural-condition-shape carries 10 cases, every kind) and pins STRUCTURAL_CONDITION_SHAPE_REFUSAL against a literal. The exhaustiveness pin covers both producers: a sweep per producer, each reaching all of its codes, and the two producers' code sets partitioning the table exactly. @ts-expect-error covers a code-less StructuralConditionRefusal and a structural refusal carrying a predicate-slot code.

Expression refusals now carry a stable code and typed params beside their English message. This covers validateExpression (every entry in errors[] and warnings[]), collectCelRootIdentifiers (its ok: false arm), predicateSlotRefusal and structuralConditionRefusal. A localized author surface, such as objectui's flow designer, can now key its own catalogue rows to the code instead of showing our English sentence. This is direction (a) from triage 5859522970. The English message is unchanged byte for byte. Nothing is removed or renamed, so it is a widening (minor for both packages).

The closed set

Codes are kebab-case and never change once published. Each code names one message template, and an optional clause (a did-you-mean, an object name) is an optional param that is present exactly when the English message carries that clause. Narrowing on code narrows params. The keys of ExpressionRefusalParams and FlowSlotRefusalParams are the closed sets. The frozen value lists EXPRESSION_REFUSAL_CODES and FLOW_SLOT_REFUSAL_CODES are held equal to those keys at compile time: each list is read off a code table that satisfies a record keyed by every code, so a missing code or an extra one does not compile.

These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case. check:error-code-casing and check:dispatcher-error-vocabulary both pass.

Inventory: every refusal each producer emits (at head 5581ed26ee)

Producer site Code Params
validate.ts:820 non-string envelope source envelope-source-not-text role, found (array/object/number/boolean/bigint/symbol/function)
validate.ts:837 template slot, other dialect template-dialect-mismatch dialect
validate.ts:847 template engine refused invalid-template detail
validate.ts:856 single brace in a template template-single-brace ref
validate.ts:862 CEL slot, other dialect cel-dialect-mismatch dialect
validate.ts:911 CEL compile failure (the "bare CEL" trailer) invalid-cel role, detail
validate.ts:901 over a parse budget cel-too-large role, detail, limit?, limitValue? (both or neither)
validate.ts:904 bare function called as a method cel-method-call role, detail, name, receiver?
validate.ts:907 name not callable here cel-unknown-function role, detail, name, suggestion?
validate.ts:909 template brace in CEL cel-template-brace role, detail, ref
validate.ts:636 unknown record. field unknown-field field, objectName?, suggestion?
validate.ts:786 role outside the catalog unknown-role name, suggestion?, catalog (list)
validate.ts:701 typo of a declared root unbound-root name, roots (list), suggestion
validate.ts:934 bare identifier, record scope bare-reference name
validate.ts:277 date arithmetic (error) date-arithmetic operands, reference?
validate.ts:293 text/boolean against a number (warning) type-mismatch operands, operator, held (text/boolean), reference?
validate.ts:966 near-miss of a field, flattened (warning) field-near-miss name, suggestion, objectName?
validate.ts:1011 traversal conflict bare-and-traversed traversal-bare-and-traversed root, field
validate.ts:1011 traversal conflict multi-hop traversal-multi-hop root, field
cel-engine.ts:343 collectCelRootIdentifiers, empty empty-expression none
cel-engine.ts:364 collectCelRootIdentifiers, parse fault (bounds included) cel-parse-failed detail
flow-node-expression-paths.ts:630 absent or null, required slot predicate-slot-missing found (absent/null)
flow-node-expression-paths.ts:647 blank after trim predicate-slot-blank none
flow-node-expression-paths.ts:672 not text predicate-slot-not-text found (envelope/array/object/number/boolean/bigint/symbol/function)
flow-node-expression-paths.ts:858 structuralConditionRefusal, neither text nor an envelope with a string source structural-condition-shape found (array/ast-without-source/object-without-source/number/boolean/bigint/symbol/function)

detail is the CEL or template engine's own diagnostic. It is English and, for CEL, includes a source excerpt. It is passed through as the message carries it.

Carriers: the least-breaking option on each producer

  • validateExpression: ExprValidationError gains code and params. It becomes a type alias ({ message, source } intersected with the correlated code/params union), no longer an interface. No repo consumer extends it or constructs one (git grep ExprValidationError hits only index.ts and two test comments). All construction goes through one internal refusal(code, params, source, message) helper, which is type-checked against its own code's params.
  • collectCelRootIdentifiers: its refusal was a plain string ({ ok: false; error: string }). error stays, byte for byte, and the failure arm gains code and params beside it (CelRootIdentifiersResult). Every consumer reads .ok, .roots and .error, and all of those are untouched.
  • predicateSlotRefusal: returns PredicateSlotRefusal, its previous { message, source } plus code and params.
  • structuralConditionRefusal: returns StructuralConditionRefusal, its previous { message, source } plus code and params (round 1).

The control: the English message is unchanged

  • One-time proof. A 43-case corpus (64 after round 1; see above) touches every arm of the three producers, including every optional-param variant and every found kind. It was run through origin/main de091b50e6 before the edit and through the branch after it. message, source, error and ok are identical on all 43 cases (0 diffs).
  • Kept as a pin. There is one test per code (expression-refusal-codes.test.ts, 21 codes; flow-slot-refusal-codes.test.ts, 4 codes). Each asserts the code, the params exactly, and the message with an exact-string toBe. Where a message embeds an engine diagnostic, the expected string takes detail from the engine itself (celEngine.compile / templateEngine.compile). That way the pin covers our words and the pass-through, never cel-js's wording.

Exhaustiveness pin, and the ablation

  • PINS is typed { [C in Code]: [Pin, ...Pin[]] }, so a code without a pin is a type error. At runtime, Object.keys(PINS) equals the code list.
  • A sweep of 2,457 validateExpression calls (3 roles × 117 inputs × 7 schema hints) and the collectCelRootIdentifiers sweep make assertions about every refusal they provoke. Each one carries a code from its producer's set. Each param value (list elements included) appears in the message, so code/params and message cannot drift apart. Each producer's sweep reaches every code that producer owns, a positive control against a vacuous sweep. The spec side sweeps 25 slot values through each of its two producers the same way.
  • Type level: in both test layers, a refusal literal without code/params, and one with another code's params, carry @ts-expect-error. Both layers are compiled (tsconfig.test.json, check:test-typecheck OK), so an unused directive would be red.
  • Ablation (committed first, done through scripts/ablation-replace.mjs with its restore armed, plus an outer trap … EXIT INT TERM): The refusal() helper was mutated to drop code for bare-reference alone. It simulates one refusal path shipping without a code.
    • Landed on disk: anchor return { source, message, code, params } as ExprValidationError; went from 1 hit to 0, the replacement from 0 to 1, and the blob from 2e753a6e8a15 to b90d3d475acd. The marker grep -c read 1 inside the run.
    • Run: expression-refusal-codes.test.ts gave 2 failed, 24 passed (26). The two reds are the bare-reference pin (expected undefined to be 'bare-reference') and the sweep (undefined from "amount * 2"). The other 20 per-code pins stayed green, so the red is specific.
    • Restored: blob 2e753a6e8a15 equals the HEAD blob, git diff HEAD is empty, and the marker count is 0.
    • Why no build leg: the test imports ./validate relatively, so the subject resolves from src/. No package exports hop and no vitest alias are involved, so no build or dist/ preflight applies.

Every consumer, and why it still compiles and behaves

git grep over the repo for each producer:

  • validateExpression: packages/lint (validate-expressions.ts, validate-null-guards.ts, validate-visibility-predicates.ts, scripts/check-doc-formula-expressions.mjs), packages/mcp (mcp-http-tools.ts), packages/services/service-automation (engine.ts). Each reads .ok, .errors[].message, .errors[].source and .warnings[], and none constructs an error object. The validate_expression MCP tool forwards errors/warnings as they are, so its answer entries now also carry code and params, an additive widening of that tool's payload that the changeset states. mcp-validate-expression.test.ts asserts toHaveLength and message regexes only.
  • collectCelRootIdentifiers: packages/lint (4 sites), packages/objectql (rule-validator.ts ×2), packages/plugins/plugin-approvals (approval-service.ts). They read .ok, .roots and .error only.
  • predicateSlotRefusal: packages/lint (validate-expressions.ts:1422) and packages/services/service-automation (engine.ts:9539) read .message and .source.
  • structuralConditionRefusal: packages/lint (validate-expressions.ts:1582) and packages/services/service-automation (engine.ts:9458, engine.ts:10766) read .message and .source. Existing tests assert toBeUndefined / toBeDefined / .message / .source only. The tests that compare against it do so relationally (toBe(predicateSlotRefusal(v)!.message)).
  • objectui at the pinned .objectui-sha (f8a9d0fb05) and at its main: screen-spec.ts and flow-sim-validate.ts call the producers and read .message / .error. flow-sim-validate.ts:162 reads structuralConditionRefusal(condition).message. No objectui test deep-equals a refusal object. A widening cannot break the Console Pin Gate's build.
  • No test anywhere deep-equals a refusal object (the grep for toEqual/toStrictEqual on these results finds only toEqual([])).

Measured at head 2a6dee312c:

  • @objectstack/lint, whose closure is spec, formula and sdui-parser (all rebuilt):
    • pnpm --filter @objectstack/lint typecheck: OK (tsc and the test layer).
    • The 7 lint test files that exercise the three producers passed, 7 files, 657 tests (flow-variable-scope, validate-expressions, validate-expressions-nonstring-source, validate-expressions.fields-value-slot, validate-null-guards, validate-visibility-predicates, validate-approval-approvers).
    • check:doc-formula-expressions, which runs validateExpression over every documented formula, passed.

NOT MEASURED locally, declared to CI: typecheck and tests for @objectstack/mcp, @objectstack/service-automation, @objectstack/objectql and @objectstack/plugin-approvals. Each needs a closure of about 20 packages rebuilt (every one a turbo cache miss after the spec change), and the shared verify lock was contended for more than an hour. What they see is additive fields on values they read only .message, .source or .error from (grep above).

For objectui#10835

This is the hand-off for the consumer half. ⛔ Nothing was posted there from this PR.

  • Import EXPRESSION_REFUSAL_CODES / ExpressionRefusalCode / ExpressionRefusalParams from @objectstack/formula, and FLOW_SLOT_REFUSAL_CODES / FlowSlotRefusalCode / FlowSlotRefusalParams from @objectstack/spec/automation. PredicateSlotRefusalCode and StructuralConditionRefusalCode narrow the spec set to each producer. A catalogue-coverage test can iterate the two frozen lists.
  • Params are values, not renderings. A list (catalog, roots) is an array that the row joins. A reference is bare text that the row quotes. found and held are tokens that the row phrases. limitValue is a number. Every other param is a string. tFormat's vars takes string | number, so only the two list params need a join before the call.
  • detail stays English (engine-owned). The row wraps it and does not translate it.
  • structuralConditionRefusal (round 1): the flow simulator's guard evaluation calls it directly at flow-sim-validate.ts:162 (evalGuard, objectui main) and shows shape.message as the step error. That is the site for the structural-condition-shape row. Its test flow-simulator.guardsInterpolation-10615.test.ts:128 asserts the step error contains the English STRUCTURAL_CONDITION_SHAPE_REFUSAL, so that assertion moves with the row.
  • The rows the designer surfaces reach today, with no schema hint (screen visibleWhen, the simulator): predicate-slot-missing, predicate-slot-blank, predicate-slot-not-text, structural-condition-shape, invalid-cel, cel-too-large, cel-method-call, cel-unknown-function, cel-template-brace, cel-dialect-mismatch, envelope-source-not-text, empty-expression, cel-parse-failed. The other codes need a schema hint (fields, fieldTypes, roleCatalog, roots, traversalHydration) or the template role.
  • Code → params:
Code Params
envelope-source-not-text role, found
template-dialect-mismatch dialect
invalid-template detail
template-single-brace ref
cel-dialect-mismatch dialect
invalid-cel role, detail
cel-too-large role, detail, limit?, limitValue?
cel-method-call role, detail, name, receiver?
cel-unknown-function role, detail, name, suggestion?
cel-template-brace role, detail, ref
unknown-field field, objectName?, suggestion?
unknown-role name, suggestion?, catalog
unbound-root name, roots, suggestion
bare-reference name
date-arithmetic operands, reference?
type-mismatch operands, operator, held, reference?
field-near-miss name, suggestion, objectName?
traversal-bare-and-traversed root, field
traversal-multi-hop root, field
empty-expression none
cel-parse-failed detail
predicate-slot-missing found
predicate-slot-blank none
predicate-slot-not-text found (envelope, array, object, number, boolean, bigint, symbol, function)
structural-condition-shape found (array, ast-without-source, object-without-source, number, boolean, bigint, symbol, function)

Acceptance notes

  • structuralConditionRefusal, folded in round 1: round 0 reported it as a same-family finding. The seat's order 5861789336 folded it in here, and the family is now closed on the producer side: the file has no third refusal producer.
  • predicateSlotRefusal's not-text arm had an unreachable value === null branch, because null returns from the first arm. The kind token is now derived first and the phrase rendered from it, so that branch is gone. Output is identical on every reachable input (the 43-case control and the pins cover every kind).
  • check:dispatcher-error-vocabulary's object-literal-constant shape (a code: followed by a SCREAMING identifier) matches a single-letter type parameter in a type literal (readonly code: K;) and reports it as an unresolvable code constant. The type parameters are named Code / Codes here, and the gate is green. Not filed: it is a precision note on a gate that failed loudly, with no author-facing reach.
  • The per-code message pins assert English copy exactly. That is deliberate for this card, whose declared control is byte-identical messages (triage 5859522970, claim 5860074154). A later rewording of a prescription updates its pin and keeps its code.

Local verification

Heavy runs went through os-verify-lock.sh, and each verdict is the lock's VERDICT command-exit 0 line.

Round 1, at head 5581ed26ee:

  • spec:
    • Targeted refusal files (flow-slot-refusal-codes, flow-node-expression-paths, flow-decision-branch-expression-absent): 3 files, 63 tests passed, run at 658a8637f5. 5581ed26ee only adds generated JSON and the changeset.
    • typecheck: OK (tsc, scripts and the test layer, ledger unchanged at 53 files / 255 errors).
    • check:generated: all 15 artifacts up to date. It first reported api-surface/ and export-origins/ stale; both were regenerated, and against the base de091b50e6 they are additive only (0 removed lines).
  • formula: the pin file passed, 26 tests (formula is untouched in round 1).
  • lint (consumer; validate-expressions.ts:1582 reads structuralConditionRefusal):
    • typecheck: OK.
    • The 7 producer-exercising files: 7 files, 657 tests passed.
    • check:doc-formula-expressions: pass.
  • Byte-identity control: 64 cases, 0 diffs.
  • Derived gates: re-derived at 5581ed26ee (the same 86 families as round 0) and reconciled with --ran: 83 run and green, 3 NOT MEASURED, 0 unrun.
    • The three NOT MEASURED are check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt. Each exits 3, PREREQUISITE NOT MET, because it reads a whole-repo build. Declared to CI.

Round 0, at head 2a6dee312c:

  • formula:
    • typecheck: OK.
    • Pin file with five producer suites: 6 files, 319 passed.
    • Full pnpm test: 40 files, 1191 passed at b0b9a29b40.
  • spec:
    • typecheck: OK.
    • Full pnpm test (local project): 554 files, 16333 passed, 1 todo.
  • Gate notes: check:dispatcher-error-vocabulary was red once, on single-letter type parameters (see Acceptance notes), and is green after the rename. check:error-code-casing: pass.
  • NOT MEASURED locally, declared to CI: typecheck and tests for @objectstack/mcp, @objectstack/service-automation, @objectstack/objectql and @objectstack/plugin-approvals (about a 20-package closure rebuild under a contended lock). They read only .message, .source or .error (grep above).
  • Not merged with main: the branch is behind origin/main, and none of the commits it lacks touches packages/formula, packages/spec/src/automation, api-surface/ or export-origins/. CI's merge ref covers the combination.

Generated by Claude Code

…ry expression refusal message

validateExpression (errors and warnings), collectCelRootIdentifiers and
predicateSlotRefusal now return `code` (a closed kebab-case set) and
`params` (the values the English message interpolates) beside `message`,
so a localized author surface can key its own catalogue rows to the code.
The English message is unchanged byte for byte.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…eset for the refusal codes

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…ulary gate does not read them as code constants

`check:dispatcher-error-vocabulary`'s object-literal constant shape matches
`code: K;` in a type literal, so a single-letter type parameter reads as an
unresolvable SCREAMING code constant. `Code` / `Codes` say the same thing
and stay out of that grammar.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/spec, touching 42 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/data-modeling/formulas.mdx (via validateExpression (symbol, a top-level function))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json) — pages documenting those are invisible to this run
  • 9 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 — 137 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 d498113b505f7b29b5e2c554f3152145edeb3461 → packageMentionDocs.

Which tree this was computed on

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

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

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

…usal too

The file's second refusal producer gets the shape predicateSlotRefusal
has: `code` ('structural-condition-shape') and `params` ({ found }) beside
the unchanged English message. The spec's code set is now one closed
table for both producers (FlowSlotRefusalParams / FLOW_SLOT_REFUSAL_CODES),
with per-producer code types, the way the formula set is laid out.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…lot refusal set; update the changeset

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 72/72 CONTRACT_REVIEW_TIER
Head-sha: 5581ed26ee2e338a563eabf3b23f1ec1137f82be

① Derived judgments

  1. Byte-identical messages: holds. Own corpus, run through tsx against throwaway worktrees at origin/main (569d4d2dbf; the four producer files equal merge-base de091b50e6) and at head: 54,189 validateExpression calls (3 roles × ~740 inputs incl. non-string/bigint/symbol/function/Date/null-proto source, ast-only envelopes, nested/deep/oversized CEL, method calls, unknown functions, braces, templates, typos, roles, date/text/boolean arithmetic, traversal × 27 schema hints) → 28,674 errors + 438 warnings; 55 collectCelRootIdentifiers inputs (50 refusals); 50 slot values × predicateSlotRefusal / structuralConditionRefusal (75 refusals). Projection of ok/message/source/error/roots: 0 diffs. Reading the diff confirms the CEL-compile arm's bounds → method → unknownFn → braces → bare fallthrough is order-equivalent to main's.
  2. Code sets: closed and complete. CODE_TABLE as const satisfies Record<Code, true> (excess/missing key is a compile error) feeds Object.freeze lists; 21 formula + 4 spec. On my sweep every refusal carries a code from its own producer's set, all 25 codes are provoked, carriers are exactly {code, message, params, source} / {ok, error, code, params}, param key sets match the declared interfaces exactly (optional clauses present iff the sentence carries them; limit/limitValue paired; no undefined keys), every non-token param value is a substring of the message, found/held tokens cover every kind (envelope, ast-without-source, object-without-source, bigint, symbol, function…). All codes match /^[a-z]+(?:-[a-z]+)+$/; no leakage (detail is engine text already in the message, limit is the public CelLimitKey, lists are copies of caller input).
  3. Public surface: correct. Formula root entry adds explicit named exports (package convention); spec's automation/index.ts is export *, so the 9 new names reach @objectstack/spec/automation; api-surface/automation.json and export-origins/automation.json gain exactly those 9, alphabetically placed with the right kinds; spec root does not re-export the file, so root.json is rightly untouched. collectCelRootIdentifiers ok:false arm: every reader in objectstack (lint ×4, objectql ×2, plugin-approvals, mcp) reads .ok/.roots/.error; objectui pin rowPredicateCanon.ts:159 reads .ok; objectui main requiredInGatedSection.ts:228 casts to a narrower CollectRoots and screen-spec.ts:180 returns it through a narrower annotation — a tsc probe against head confirms both compile. No deep-equal or spread of any of the four results in either repo. ExprValidationError interface→alias: no extends/implements/constructor anywhere (grep hits only formula index.ts); objectui pin's local FormulaModule type and main's optionLint.ts/flow-sim-validate.ts read .message only.
  4. Pins: sound. Mapped-type PINS over every code, runtime key equality, per-producer sweeps that must reach every code, spec partition test. My tsc probe: code-less literals rejected on all four carriers, unknown code rejected, foreign params rejected, narrowing on code narrows params. A new errors.push({source,message}) or a new {ok:false,error} arm is red at typecheck; a new table key without a pin is red at the test's mapped type.
  5. Changeset: every sentence true, minor for both, Clause-②: yes alone on line 14. MCP validate_expression forwards errors/warnings verbatim (mcp-http-tools.ts:688), so that sentence holds.
  6. Hand-off: accurate. objectui main flow-sim-validate.ts:162 is structuralConditionRefusal(condition) in evalGuard, :163 shows shape.message; test :128 asserts STRUCTURAL_CONDITION_SHAPE_REFUSAL; tFormat takes Record<string, string | number>; the no-hint code list matches screen-spec.ts and evalCelPredicate (both call with no schema). Non-blocking body inaccuracy: the consumer census says the pinned objectui's screen-spec.ts/flow-sim-validate.ts call the producers; at f8a9d0fb05 neither imports @objectstack/formula/spec — the pin's readers are celAuthoring.ts:502 and rowPredicateCanon.ts:159. Conclusion unchanged.

② Semver level

minor for @objectstack/formula and @objectstack/spec is right. Per scripts/pm/clause2-line.mjs, Clause-②: yes with no arm reads as a widening that takes at least minor; the diff adds exported fields, types and two frozen lists and removes/renames nothing. The required code/params on result types could only bite a consumer that constructs or extends those values, and none exists in objectstack or objectui (pin or main), so no (narrowing) arm is warranted. CI Check Changeset green at head.

③ Boundary flags

  • NOT-MEASURED packages (mcp, service-automation, objectql, plugin-approvals): covered by CI at head — Type Check · workspace (turbo run typecheck --filter='./packages/*' --filter='./packages/*/*') and Test Core (1/6…6/6) (turbo run test, whole workspace partitioned by package), all success; 42 check-runs read, 0 failures, 5 skipped by path filter (incl. Console Pin Gate; .objectui-sha unchanged).
  • git merge-tree --write-tree origin/main 5581ed26ee against current origin/main 569d4d2dbf: clean, tree 1600ba698e, exit 0.

Implemented-by: claude/issue-20291-expression-refusal-codes
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 03:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 862b6ce Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20291-expression-refusal-codes branch September 28, 2026 04:05
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…stem-* migration entries states each lesson in words, not tracker numbers (stage 3) (objectstack-ai#20384)

Part of objectstack-ai#20233

Clause-②: no

**Stage 3 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `surface`, `from`
/ `to`, conversion or matching logic moves, and the chain rewrites
exactly what it rewrote before.

## What this does

`os migrate meta` prints every ADR-0087 semantic entry it crosses as one
block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's
`reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's
runtime-string rule applies to all of it: 「Runtime strings — refusal
prose, prescriptions, anything an author is shown — carry no tracker
number (`pnpm check:doc-authoring`): the lesson goes into the text.」
Form **D** of ruling C+D on the parent card sets the shape: the lesson
in words, and no number, dead or alive.

This stage covers the next three families by site count, `driver-`,
`kernel-` and `system-`: **132 sites → 0** in the three prose fields.
None of the 26 entries carries a tracker id in `surface` (ruling A of
the stage-1 ACCEPT, `5858839916`, is checked and has nothing to do
here). Each site now says what the cited ruling, measurement or fix
decided. ADR ids stay. `registry.ts`, `spec-changes.json` and
`docs/protocol-upgrade-guide.md` are regenerated from the entries
(`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`),
never hand-edited. The stage-1 pin now holds `engine-`, `ui-`,
`plugin-`, `driver-`, `kernel-` and `system-`.

## Census — tracker ids in the author-shown fields

**Instrument.** The stage-2 AST instrument, unchanged: a TypeScript-AST
walk over every `packages/spec/src/migrations/entries/**/*.ts`. For each
`entry` object literal it evaluates the string value of `replacement`,
`reason`, `acceptanceCriteria` and (counted separately) `surface`,
joining string literals with `+`, then counts `#` followed by 4 or 5
digits at a word boundary. **Validated first** by reproducing the
stage-1 readings on the stage-1 tree (`443b2f4fdc`, extracted with `git
archive`): `driver-` 7 entries / 44 sites (0 / 44 / 0, 25 distinct),
`kernel-` 9 / 44 (1 / 41 / 2, 11 distinct), `system-` 10 / 44 (0 / 42 /
2, 6 distinct), `engine-` 5 / 67, whole tree 266 entries / 1,016 sites /
9 `surface` sites — every figure equal to the stage-1 census. **Tree
measured:** `objectstack-ai/objectstack` at `569d4d2dbf` (this branch's
base). Unevaluable fields: 0.

**Controls, same run.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), the reading stages 1 and 2 took.
- **Dark (comment lines):** 794 `//` lines in entry files carry a
tracker id, and none is counted. Comment lines belong to the sibling
card, and ⛔ this PR touches none (794 before and after).
- **Dark (field boundary):** the 7 `surface` sites left in the tree
(other families) count 0 in the three-field total and 7 in the `surface`
column.

**Re-measured on the base, matching the stage-1 census:** `driver-` 7
entries, **44** sites (replacement 0 / reason 44 / acceptanceCriteria
0), 25 distinct ids; `kernel-` 9 entries, **44** (1 / 41 / 2), 11
distinct; `system-` 10 entries, **44** (0 / 42 / 2), 6 distinct. 37
distinct ids across the three (the families share `objectstack-ai#14478`, `objectstack-ai#15939`,
`objectstack-ai#17635` and `objectstack-ai#3733`). `surface`: 0 in all three. Whole tree: 300
entries, **843** sites, 7 `surface` sites.

**After this PR:** `driver-` 0, `kernel-` 0, `system-` 0; `engine-`,
`ui-`, `plugin-` still 0; whole tree **843 → 711** sites; `surface` 7
(unchanged, other families).

| entry | sites (replacement / reason / acceptanceCriteria) |
|---|---|
| `17.driver-aggregate-undeclared-key-aliases-removed` | 6 (0 / 6 / 0) |
| `17.driver-capabilities-inert-bits-removed` | 4 (0 / 4 / 0) |
| `18.driver-options-timeout-to-timeout-ms` | 1 (0 / 1 / 0) |
| `17.driver-sql-distinct-bare-filter-typed` | 9 (0 / 9 / 0) |
| `18.driver-sql-unresolvable-where-column-refused` | 14 (0 / 14 / 0) |
| `18.driver-sql-upsert-cross-row-identity-merge-refused` | 9 (0 / 9 /
0) |
| `18.driver-turso-config-local-path-wasm-retired` | 1 (0 / 1 / 0) |
| `18.kernel-compatibility-matrix-estimated-migration-time-unit-in-key`
| 5 (0 / 5 / 0) |
| `18.kernel-context-preview-mode-retired` | 5 (1 / 4 / 0) |
| `18.kernel-event-bus-retention-unit-in-key` | 3 (0 / 3 / 0) |
| `18.kernel-health-check-and-hot-reload-durations-unit-in-key` | 7 (0 /
5 / 2) |
| `18.kernel-package-lifecycle-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.kernel-plugin-health-report-durations-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.kernel-plugin-security-durations-unit-in-key` | 4 (0 / 4 / 0) |
| `18.kernel-runtime-config-timeout-unit-in-key` | 11 (0 / 11 / 0) |
| `18.kernel-startup-orchestrator-durations-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.system-cache-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-collaboration-durations-unit-in-key` | 4 (0 / 4 / 0) |
| `18.system-failover-health-check-interval-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.system-metrics-jsdoc-durations-unit-in-key` | 12 (0 / 12 / 0) |
| `18.system-metrics-window-durations-unit-in-key` | 5 (0 / 3 / 2) |
| `18.system-object-storage-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-registry-config-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-tracing-otel-exporter-durations-unit-in-key` | 5 (0 / 5 /
0) |
| `18.system-tracing-span-duration-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-worker-queue-rate-limit-duration-unit-in-key` | 3 (0 / 3 /
0) |
| **total, 26 entries** | **132 (1 / 127 / 4)** |

## Every citation read, and what the text now says

I read each cited issue or PR myself with single-card REST reads: the
body, and the comments where a ruling or a measurement lives. Ids are in
code spans so this body posts no cross-references. All 36 bare ids were
resolved against this repository, because every sentence that cites one
is about this repository's code; the one cross-repo id is `cloud#1651`.

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectstack-ai#3733` | The pruned `cached` field key: measured, the parse succeeded
and the removed key was dropped without a word; the orphan schema was
deleted. | "an earlier field-key prune measured exactly that — the parse
succeeded and the removed key was dropped without a word" (health-check,
OTel exporter) |
| `objectstack-ai#3821` | The sharing-rule page: an unsortable query fell through to
an empty page, and the driver fix made an unsortable query lose its
ORDER BY, not its rows. | "the unknown-column recovery ladder (an
unsortable query loses its ORDER BY, not its rows)"; "the ladder's own
premise — rows matter more than their order"; "the ladder's recoveries"
|
| `objectstack-ai#4484` | `IDataDriver.findStream` removed: no production caller, two
of three implementations buffered the whole set, and no tombstone
because nothing parses a driver object. | "Retiring
`IDataDriver.findStream` (it had no production caller, and two of its
three implementations read the whole result set into memory …)";
"(`IDataDriver.findStream`, removed with no tombstone because nothing
parses a driver object)"; by entry id in the `distinct` entry |
| `objectstack-ai#4583` | The datasource ledger's dead keys removed; `capabilities.*`
went as a whole block (11 of 11 unread). | "was retired separately, as a
whole block nothing read" |
| `objectstack-ai#4634` | Audit of all 34 `DriverCapabilities` bits: 3 live, 31 dead
and tombstoned. | the entry already states the audit ("the follow-up
audit checked every bit"); the trailing id is dropped |
| `objectstack-ai#4914` | Maintainer, 2026-08-04: remove `manifest.loading` and
`PluginHotReloadSchema`; keep `HotReloadConfigSchema`, the side with an
implementation (`HotReloadManager`), as the start point. | "kept twice:
as the hot-reload vocabulary that had an implementation when the
manifest-side copy was removed, …" |
| `objectstack-ai#4984` | An org-axis red-line gate read only aliases the schema
rejects while its own fixtures spelt them: tests green, rule dead. |
"the family of the org-axis red-line gate that read only rejected
aliases while its own fixtures spelt them, so its tests stayed green and
the rule stayed dead" |
| `objectstack-ai#5181` | Narrow the query parameter of `IDataDriver`'s methods
(`DriverQuery`, no redundant `object`). | "neither the narrowing of
`IDataDriver`'s query parameters to `DriverQuery` nor the follow-through
…" |
| `objectstack-ai#5499` | Maintainer, 2026-08-05: freeze investment in `driver-memory`
/ `driver-mongodb`; fully lifted 2026-08-11 (comments `5249019855`,
`5252526378`). | "the maintainer's 2026-08-05 investment freeze on
driver-memory, which was lifted on 2026-08-11" |
| `objectstack-ai#5540` | Remove `IStorageService.list(prefix)`: zero consumers, and
the two adapters answered differently and both incompletely. | "(the
zero-consumer `IStorageService.list`, whose two adapters answered
differently and both incompletely)" |
| `objectstack-ai#6011` | Maintainer: close the `ctx.user` `roles` alias now. | "(the
`ctx.user` `roles` alias, closed at once on the maintainer's word rather
than given a window)" |
| `objectstack-ai#6075` | **404** — see Acceptance notes. | "the follow-through that
brought five drivers' implementations in line" |
| `objectstack-ai#6320` | `distinct`'s third argument meant different things on memory
and sql; the sql half was dispatched, the memory half held under the
freeze. | "(the measurement that found the two drivers reading this
argument differently split the fix: the sql half is this entry, and the
memory half was held back by that freeze)" |
| `objectstack-ai#6321` | `query.aggregate` / `agg.func` are undeclared aliases whose
only writers are driver fixtures; order: re-spell the fixtures, delete
the aliases, then narrow the signature. | "The removal ran in a fixed
order — the fixtures re-spelt first, the two alias branches deleted
second, the parameter narrowed to `DriverQuery` last — because the
reverse order yields red nobody can explain." |
| `objectstack-ai#6404` (PR) | Executed that order and narrowed `aggregate`'s query
parameter to `DriverQuery`. | the same sentence |
| `objectstack-ai#7929` | Maintainer, 2026-08-12, ruling B: `driver-sql`'s filter
refusal stops echoing `$field` operands, for every caller; the full
diagnostic goes to the server log. | "the same predicate-text disclosure
shape the driver's field-reference filter refusals had already been made
to stop echoing (the full diagnostic goes to the server log, never the
response)"; "that disclosure shape closed on the last dialect" |
| `objectstack-ai#8371` | Ruled option 2: a dotted filter key whose head is a
relation, a formula or a scalar is refused at both doors; a structured
head stays unjudged. | "the axis owned by the dotted-filter verdict,
which refuses a dotted key whose head is a relation, a formula or a
plain column at the protocol and engine doors" |
| `objectstack-ai#8592` | Measured on live MySQL: knex compiles the named conflict
target away. | stated by the entry ("knex drops the named keys before
the statement leaves the process"); the trailing id is dropped |
| `objectstack-ai#8621` | Option A: a pre-flight refusal when no unique index backs
the caller-named conflict target. | "Two earlier pre-flight refusals
closed the half where no unique index backed a caller-named target …" |
| `objectstack-ai#8622` | `id` becomes insert-only on the merge path: a merge on a
non-primary conflict key was measured rewriting the existing row's
primary key. | "`id` is insert-only on the merge path (made so once a
merge on a non-primary conflict key was measured rewriting the existing
row's primary key)" |
| `objectstack-ai#8755` | Ruling option A: a pre-flight refusal when a second unique
key could absorb a backed, caller-named target. | "… and the half where
a rival unique key could absorb a caller-named one" |
| `objectstack-ai#8790` | Maintainer, 2026-08-15: refuse both halves with
`INVALID_FILTER` / 400, naming the column. | "Ruled by the maintainer on
2026-08-15: refuse BOTH halves …"; "Recover-both was excluded by the
ruling's own argument" |
| `objectstack-ai#8807` | Maintainer, 2026-08-15: an upsert must never modify a row
whose identity the caller did not supply and whose conflict key it did
not name; enforcement delegated, blanket refusal excluded. | "Ruled by
the maintainer on 2026-08-15, as a contract principle …" (the principle
itself was already quoted verbatim) |
| `objectstack-ai#8926` | Maintainer, 2026-08-16, option A: MySQL's spelling joins the
one shared predicate (envelope and recoveries together). | "Addendum
2026-08-16." — the paragraph already states option A |
| `objectstack-ai#9061` (PR) | Implemented that option A. | the same |
| `objectstack-ai#11825` | Maintainer, 2026-08-25: retire the declarative
`AdvancedPluginLifecycleConfig` container; the classes stay a
host-driven library. | "… and as a host-driven library when the
declarative lifecycle config container was retired" |
| `objectstack-ai#11846` | **404** — see Acceptance notes. | "maintainer ruling
2026-08-27 (Option A: remove)"; "(as the removal ruling recorded)" |
| `objectstack-ai#14478` | Ruling B, 2026-09-02: a no-baseline gate plus an ADR-0087
rename of every offender (`DriverOptions.timeout` among the seven
named); ruling B again, 2026-09-05: the population is every authored and
every runtime-emitted duration, minus exemptions declared on the schema.
| "Maintainer ruling B on duration units (2026-09-02, its population
widened on 2026-09-05 to every authored and every runtime-emitted
duration, bar the exemptions a schema declares on the key itself)"; "the
duration-unit rule (…)" |
| `objectstack-ai#14519` | The two tenant timeouts published a describe naming no unit
(the unit sat in the JSDoc only); folded into the rename. | "the
unit-nowhere shape (no unit in the name or in the published describe,
first measured on two tenant timeouts)" |
| `objectstack-ai#15626` (PR) | Landed the gate and the seven founding renames, the
tenant `idleTimeout` → `idleTimeoutSeconds` among them. | "The tenant
half was already renamed, in the same change that landed the duration
gate itself" |
| `objectstack-ai#15678` | `kernel/`: the 14 remaining duration keys carry their unit
in the key name. | "the kernel-directory duration renames" / "the
kernel-directory round"; trailing ids dropped |
| `objectstack-ai#15679` | `system/`: the 15 remaining duration keys carry their unit
in the key name; `size` got an honest name. | "the system-directory
duration round"; trailing ids dropped |
| `objectstack-ai#15939` | The gate did not read JSDoc. Ruled 2026-09-07: refuse the
JSDoc / describe divergence. Ruled A 2026-09-11: remediate the
population per file first, land the widened gate last. | "Director-seat
ruling A of 2026-09-11 on the JSDoc-channel finding … a duration key
whose JSDoc names a unit its describe does not is refused, and the keys
in that shape are remediated per file before that refusal lands" |
| `objectstack-ai#16024` | Maintainer, 2026-09-06, per key: forward `timeout`, remove
`localPath` and `wasm`. | "ruled per key by the maintainer on
2026-09-06, once all three of this package's unread config keys had been
measured" |
| `objectstack-ai#17635` (PR) | The widened gate: refuse a duration key whose JSDoc
names a unit its describe does not; landed last. | "lands that widened
gate last, into a tree already clean" |
| `objectstack-ai#18669` | Ruling A, 2026-09-17: rename `FileValue.duration` and
`estimatedMigrationTime`, each with an ADR-0087 entry; no new closed
type, no narrowing of stored data. | "Maintainer ruling A of 2026-09-17
on the last two duration keys no closed duration type could express …" |
| `cloud#1651` | **Not readable from this session** — see Acceptance
notes. | "cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a
routing-only switch …" |

No call-shaped token moves: a `name(` census over `registry.ts` is
identical before and after (297 distinct tokens), so textual
call-spelling ratchets read the same.

## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`,
widened

`COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`,
`kernel-`, `system-`. The pin still spawns the real CLI (`os migrate
meta --from 16 --to 18`) once, locates each covered block **verbatim**
in stdout, and asserts the printed block — `surface` included — carries
no `#` plus 4 or 5 digits. Anti-vacuity:
- the `REWRITTEN` floor rises from 29 to **55** ids: the 26 entries of
this stage (7 `driver-`, 9 `kernel-`, 10 `system-`) are added, and every
covered prefix must still select at least one entry;
- presence in stdout is asserted before cleanliness (the
`driver-sql-unresolvable-where-column-refused` reason carries two
blank-line paragraph breaks, and its block is found verbatim);
- the detector is exercised on both sides first (lit on 4 and 5 digits,
dark on 3, 6 and `ADR-0112`).

The file keeps its stage-1 name; the header lists the six covered
families.

## Ablation — the widened pin can fail on a `driver-` block

From committed state, HEAD `1c0dc7ad54`, with
`scripts/ablation-replace.mjs` in wrap mode (it owns the restore trap)
and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is
built from the generated `registry.ts`, so that is the file mutated.
- **Mutation.** In `registry.ts`, the reason of
`driver-sql-upsert-cross-row-identity-merge-refused`: anchor `pre-flight
refusals closed the half` → `pre-flight refusals (objectstack-ai#8621) closed the
half`. The tool read anchor 1 → 0 and replacement 0 → 1, blob `b41e1d44`
→ `8f8d226e`.
- **Mutate leg** (one lock turn: build, preflight, pin). Spec build exit
0. Preflight: marker present in 4 built files. Pin: **red**, `1 failed |
2 passed` — `driver-sql-upsert-cross-row-identity-merge-refused: the
printed guidance cites a tracker id: expected 'objectstack-ai#8621' to be undefined`.
- **Restore.** Tool-proven: blob `b41e1d44` == HEAD, `git diff HEAD`
empty.
- **Restore leg.** One lock turn, taken on the second try (the first
waited out its 540 s budget, exit 99, NOT MEASURED, the tree already
restored). Spec build exit 0. The `--absent` preflight found the marker
in none of 222 built files, with the working tree clean against HEAD.
Pin: **green**, `3 passed`.

## Verification

Final head **`1c0dc7ad54`** for every line below; each heavy run went
through `scripts/pm/os-verify-lock.sh` (one turn, `VERDICT command-exit
0`, per-step exits recorded separately).

- **Build:** `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/cli^...'` gives `Tasks: 55 successful, 55 total`.
- **Pin and its neighbour:** `pnpm --filter @objectstack/cli exec vitest
run --project integration --maxWorkers=2
test/migrate-meta-engine-guidance.test.ts
test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`,
`Tests 10 passed | 1 skipped` (the skip is the default-range file's own
pre-existing `skipIf`).
- **Spec tests that read these entries or the registry:** `pnpm --filter
@objectstack/spec exec vitest run --maxWorkers=2 src/migrations
src/kernel/preview-mode-retirement.test.ts
scripts/build-schemas-check-mode.test.ts` plus the 19 other spec test
files that read `MIGRATIONS_BY_MAJOR`, the registry or an entry file:
`Test Files 24 passed`, `Tests 696 passed`.
- **CLI unit:** `test/vitest-tiers-partition.test.ts` and
`src/utils/spec-release-changes.test.ts`: `Test Files 2 passed`, `Tests
28 passed`.
- **The call-spelling census that reads `registry.ts`:** `pnpm --filter
@objectstack/driver-sql exec vitest run --maxWorkers=2
src/sql-driver-query-signature.test.ts` gives 15 passed.
- **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exits 0
(test layer: 53 files / 255 errors held in its ledger); `pnpm --filter
@objectstack/cli typecheck` exits 0 (test layer: 3 files / 28 errors
held, unchanged).
- **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives **89** families at
`1c0dc7ad54` (after `git fetch origin main`). `--ran` over the recorded
exit codes reads **89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN**, all
exit 0. They include `check:doc-authoring` ("16466 customer-facing
string(s) across 1135 spec sources clean"), `check:issue-citations`,
`check:migration-registry` ("registry.ts is current (300 semantic, 219
retired-key, 199 retired-def)"), `check:spec-changes`,
`check:upgrade-guide`, `check:generated` ("All 15 generated artifacts
are up to date"), `check:duration-unit-keys`, `check:nul-bytes`,
`check:adr-0087-registration` and `check:changeset-no-major`.
- `check:dual-build-cjs-loads` refused first with `PREREQUISITE NOT MET`
(exit 3: twelve packages outside the CLI closure had no `dist/`). Those
`dist/` directories were written later in the same pass (04:48–04:49Z,
inside the `check:type-check-debt` run, whose re-measure builds them);
re-run at the same head it exits 0 (104 entries / 66 packages / 659 CJS
files). The reconciled list takes that latest run.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 28 changed `.ts`
files reports 28 files, 0 errors, 0 warnings.
- The population is read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 28
are in it (no file-ignored warning).
- Invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so a text edit cannot move the
verdict on a file it does not touch.
- **Mergeability:** see Acceptance notes (driver-free `merge-tree`
against `862b6ce869` exits 0).

## Acceptance notes

- **Two dead ids, rewritten from the code on `main`.** `objectstack-ai#6075` and
`objectstack-ai#11846` answer 404 on both the issues and the pulls endpoint, re-probed
with a 200 control (`objectstack-ai#14478`).
- `objectstack-ai#6075` (`distinct`'s "never reached it" sentence):
`packages/drivers/driver-sql/CHANGELOG.md` (commit `d367f03`) and
`sql-driver-query-signature.test.ts` record what it did — the five
drivers' implementations followed `IDataDriver`'s `DriverQuery`
narrowing. The sentence now says exactly that.
- `objectstack-ai#11846` (preview mode): `packages/spec/CHANGELOG.md` (commit
`0c2334f`), `packages/spec/src/kernel/context.zod.ts` and
`preview-mode-retirement.test.ts` record the 2026-08-27 ruling (Option
A: remove), the three-repo zero-consumer measurement and the
re-declare-fresh condition. Dropped because `main` does not state them:
"decision-inbox batch 2" and "all four decision facets pointed the same
way". "The objectstack-ai#11846 card records the measurement" (objectui leg) now reads
"zero consumers, measured when the removal was ruled", which is what the
changelog and the test header say.
- **One cross-repo id this session cannot read.** `cloud#1651` answers
403 here: `objectstack-ai/cloud` is not attached to this session
(`add_repo` refused: no access). It is neither confirmed nor refuted, so
its sentence was rewritten from what `main` records about it —
`packages/spec/CHANGELOG.md` (`0c2334f`: closed 2026-08-26 with positive
controls, `RuntimeMode` zero hits, `ArtifactKernelFactory` 20+ hits and
never touching `previewMode`) and `context.zod.ts` (`OS_PREVIEW_MODE`
there is routing-only). The cloud-side detail `main` does not state —
`previewMode` "only as a local variable" whose effect is adding
wildcards to "CSRF" trusted origins — is dropped; the parenthesis that
replaces it describes this repository's own `serve.ts` (the one reader
of `OS_PREVIEW_MODE` here only widens better-auth's trusted origins to
preview-domain wildcards), which is measured on `main`.
- **Decision-batch numbers went too (invisible to the regex).** Nineteen
sites cited a decision batch as `#` plus two or three digits (`objectstack-ai#43` ×13,
`objectstack-ai#115` ×4, `objectstack-ai#151` ×1, `objectstack-ai#158` ×1). They are numbers an author is shown
and cannot follow, so each is dropped. One consequence worth naming: 13
entries said `Maintainer ruling B on objectstack-ai#14478 (2026-09-02, decision batch
objectstack-ai#43)`, which fused two rulings on the same card — B of 2026-09-02 (the
gate and the no-baseline rename) and B of 2026-09-05, decided in that
batch (the population: every authored and every runtime-emitted
duration, minus schema-declared exemptions). The sentence now names both
dates. The `objectstack-ai#158` sentence (the agreement shape ruled an offence on
2026-09-18) is corroborated by
`.changeset/18075-agreement-shape-is-an-offence.md` on `main`.
- **"issue NNNN" / "PR NNNN" spellings, checked by hand.** No
bare-number spelling exists in these 26 entries' author-shown text; the
two `PR` citations (`PR objectstack-ai#6404`, `PR objectstack-ai#9061`) were `#`-spelled, so the
instrument saw them and they are gone. The only `#` left in these 26
files is on `//` comment lines (sibling card's surface), including a
`Prime Directive objectstack-ai#13` reference.
- **A citation whose page says something narrower than the text.**
`kernel-health-check-and-hot-reload-durations-unit-in-key` called
`shutdownTimeout`'s shape "the objectstack-ai#14519 unit-nowhere shape". `objectstack-ai#14519`'s
keys carried their unit in the JSDoc; "unit nowhere" is the gate's name
for it (`check-duration-unit-keys.ts` header: "no unit ANYWHERE (the
objectstack-ai#14519 shape)"), because the gate did not read JSDoc. The sentence now
says what the shape is — no unit in the name or in the published
describe — and that it was first measured on two tenant timeouts.
- **A comment that my text edit makes slightly stale.**
`18.system-metrics-window-durations-unit-in-key.ts` carries a `//`
comment saying its acceptanceCriteria sentence "is objectstack-ai#15679's, left word
for word". That sentence now says "that JSDoc-channel gap is filed as a
finding of its own" where it said "is objectstack-ai#15939": same content, no number.
The comment is the sibling card's surface (comment lines), so it is
untouched here.
- **Three "card" references re-anchored.** Removing an id left "the same
card" in the Turso entry pointing at nothing; it now says "the same
measurement". The kernel entries' "renamed by this same card" carry no
number and were not otherwise rewritten, so they are left.
- **Cross-PR check: no open PR adds or edits a `driver-`, `kernel-` or
`system-` semantic entry.** Read at 2026-09-28T04:0xZ: the 18 open PRs'
file lists (`GET /pulls/{n}/files`) carry 0 files matching
`migrations/entries/semantic/NN.(driver|kernel|system)-*`. The Version
Packages PR (`objectstack-ai#17076`) lists more than 1,000 files; the 1,100 rows read
carry no entry file, and it is the bot-generated release PR. Nothing in
flight will be held by the widened pin on arrival.
- **`main` moved 4 commits past the base** (`862b6ce869`: `objectstack-ai#20364`,
`objectstack-ai#20341`, `objectstack-ai#20366`, `objectstack-ai#20352`); none touches
`packages/spec/src/migrations/` or the pin. A driver-free bare-clone
`merge-tree --write-tree` of this head against `862b6ce869` exits 0 with
no conflicted path, so `registry.ts` needs no merge, and `main` was not
merged in.
- **Generated projections** (`spec-changes.json`,
`docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1 and 2;
their `--check` legs are green. Only the three `driver-` entries
registered at protocol 17 appear in them, which is why those diffs are
small.
- **No other test pins these entries' text.** A `git grep` of test files
for the 26 entry ids finds one (`preview-mode-retirement.test.ts`),
which names the entry in a comment and reads no prose; a grep of tests
for the 37 cited numbers finds only comment lines. So no test needed
re-pinning this stage (stage 2's `migrations.test.ts` case has no
counterpart here).

## Line budget

Entry files: **352 changed lines** (+228 / −124) across 26 files,
against the stage-1 ≈400 budget. The whole diff is **776 lines** (+516 /
−260) in 31 files. Of the rest, `registry.ts` is 352, the two
projections are 18 (`spec-changes.json` 12, the upgrade guide 6), the
widened pin is 33 and the changeset is 21.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…(ADR-0049) (objectstack-ai#20398)

Fixes objectstack-ai#20323

Clause-②: yes

## What this does

Retires `action.aria` under ADR-0049 enforce-or-remove, following the
triage direction on the card (RETIRE, on the chart-config precedent
`2bf6ef18d`). No maintainer word reversed it to ENFORCE.

- **Schema.** `ActionSchema.aria` becomes a `retiredKey()` tombstone. It
is a `tsc` error (the input type is `never`) and a parse error that
carries the prescription. The accessible name that IS applied is the
action's required `label`, and the placing node's `aria` block
(`page.components[].aria` or the list view `aria`) names the region.
`AriaPropsSchema` is untouched: this is a key retirement, not a def
retirement.
- **ADR-0087.** The D2 conversion `action-aria-removed` (protocol 18,
`retiredFromLoadPath`) strips the key from `actions[]` and
`objects[].actions[]` as a lossless delete. The D3 semantic entry
`action-aria-retired` is its own family, per the one-entry-per-family
rule. The retired key `ui/Action:aria` is registered under 18, the major
of the chart-config sibling.
- **Ledger.** `liveness/action.json` regrades `aria` from `live` to
`dead`. Its `REMOVED` note records that the uncited 「PARTIAL — honored
by a few objectui renderers」 claim had no reader behind it. The
undrilled-container row `action/aria` goes, and `state-counts.md` is
regenerated.
- **Docs.** Two hand-written pages taught `aria` on an action and are
corrected: `protocol/objectui/actions.mdx` and `widget-contract.mdx`.
The three reference pages that render `ActionSchema` are regenerated.
- **Changeset.** `minor`, BREAKING, with `Clause-②: yes`, the FROM → TO
table and the ADR-0087 `registered` marker.

## Premise, re-measured on this checkout

- **objectui at the `.objectui-sha` pin `f8a9d0fb05`.** A reader grep
for an action's `aria` (`action`, `actionDef`, `def`, `spec`, `btn`,
`a`, `act`, `item` followed by `.aria`) over `packages/**` and `apps/**`
non-test sources hits **0** lines. The control, the same grep for
`.variant`, hits **18** files. Every `schema.aria` reader there is a
placing node: the `record:*` components, `ListView`, `ObjectView` and
`element:button`'s props.
- **Icon-only reversal condition.** Triage named "icon-only actions have
no accessible name" as the condition that would reverse this to ENFORCE.
It does not hold. `action-icon.tsx:243` renders
`aria-label={schema.label || schema.name}`, `action-menu.tsx:341`
renders `aria-label={schema.label || moreActionsLabel}`, and
`action-button.tsx:346` renders `{schema.label}` as the visible text.
- **Framework.** A grep for `.aria` over `packages/**` non-test TS
outside `packages/spec` hits **0** lines. `action.form.ts` has 0 `aria`
rows; its one hit is the `variant` substring.
- **Authors in this repo.** Across `examples/**`, the published
`skills/**` and `packages/**` fixtures, **0** actions author `aria`. The
control is **15** `variant:` lines in `examples/**`.
- **Authors in HotCRM `2f7b2326`.** **0** actions author `aria`. Its 6
`aria:` blocks are all page-level `page.aria`, which stays live. The
control: 7 files under `src/**/actions/` declare `locations:`, 17 times.
- **Served schema.** `metadata-protocol` drops a tombstone's `{ not: {}
}` node from the served JSON Schema, via `stripUnauthorableProperties`.
So the Studio "More fields" form stops offering `aria` once this ships.
- **Pinned sibling.** objectui's `ActionRunner.ts:410` mirrors the key
as `aria?: SpecActionInput['aria']`. A type probe against this branch's
built `dist` compiles that mirror at exit 0, because it evaluates to
`undefined`. An authored block on it is refused with TS2322, and the
control leg without `@ts-expect-error` exits 2. So the Console Pin
Gate's objectui build is not broken by this change.

## Hand-over review (the stopped run's six commits, read hunk by hunk)

| commit | verdict |
| --- | --- |
| `0c5dbbff` sources, ledger, tests, changeset | **kept, with
corrections.** The prescription said "removed in @objectstack/spec 17";
it now says `17.5.0`, the spelling every sibling retirement on this line
uses. The refusal pin in `action.test.ts` now also asserts the
tombstone's own issue kind (`invalid_type` at `aria`, and no root
`unrecognized_keys`, which is what a bare deletion would answer
instead). The changeset's BREAKING sentence now names the replacement.
The HotCRM control is re-measured: 7 files and 17 lines, not "5 action
files". |
| `6334c137` regenerated artifacts | kept, and re-derived after both
merges |
| `bfc86375` the stored-row pin uses a parseable script action | kept |
| `0f20c62f` the `action/aria` undrilled-container row goes | kept;
`check:liveness` is green |
| `8cbcfa46` merge of `main` | kept |
| `90b8fcdc` regenerated state counts | kept; superseded by the
post-merge regeneration |

Added in this round:

- `33407119` and `c38c18ae` merge `origin/main` through
`scripts/pm/os-regen-merge.sh`. Both merges stopped on the two
registries. Each was settled by stacking both sides: main's conversions
and rationale paragraphs first, then this branch's. The merges brought
in objectstack-ai#20262, objectstack-ai#20352, objectstack-ai#20251 and objectstack-ai#20353.
- `2b15085d` and `7faf0e9f` are the deferred regenerations.
- `68b038ef` carries the corrections above, plus two more:
- a new pin that the stored-row seam reaches an `object` row's nested
action (the changeset's second at-rest coordinate);
- one sentence on the `action` row of `liveness/README.md`. That row
said "makes the dead set three", which this change makes false in a
published file (`liveness` is in `@objectstack/spec`'s `files`).
- `8dd3a2ce` regenerates the reference pages for the 17.5.0
prescription.

## Verification

Head `7faf0e9f`, on `origin/main` `15bf186f`:

- `pnpm --filter @objectstack/spec test`: `Test Files 558 passed (558)`
· `Tests 16509 passed | 1 todo (16510)`.
- `pnpm --filter @objectstack/spec test:repo`: `Test Files 35 passed
(35)` · `Tests 634 passed (634)`.
- `pnpm --filter @objectstack/spec check:generated`: all 15 generated
artifacts up to date, measured over a spec build made on this head.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 114
commands. All 114 exit 0, and `--ran` reconciles them as `114 derived,
114 run, 0 NOT-MEASURED, 0 UNRUN`. The run covers every package's build
closure, rebuilt on this head, so the gates that read `dist` measured
it.

Head `8dd3a2ce`, before the second `main` merge, on `origin/main`
`862b6ce8`. The second merge touched none of these packages' interaction
with this diff; the incoming commits retire other keys and touch no
action surface.

| package | command | Test Files | Tests |
| --- | --- | --- | --- |
| `@objectstack/lint` | `vitest run` | 112 passed | 4640 passed |
| `@objectstack/cli` | `vitest run --project unit` | 231 passed | 3309
passed |
| `@objectstack/runtime` | `vitest run --project local` | 282 passed |
4059 passed, 1 skipped |
| `@objectstack/metadata-protocol` | `vitest run` | 189 passed, 3
skipped | 2736 passed, 19 skipped |
| `@objectstack/metadata-core` | `vitest run` | 16 passed | 285 passed |
| `@objectstack/objectql` | `vitest run --project local` | 322 passed |
5857 passed |

`pnpm --filter @objectstack/spec typecheck` was green at the same head.
The cli `integration` layer is declared to CI: this diff touches no
spawn entry and no integration file.

**Ablation**, from the committed state at `8dd3a2ce`. The blobs of
`action.zod.ts` and of the three pin files are byte-identical at
`7faf0e9f`.

- The mutation goes through `scripts/ablation-replace.mjs`: the anchor `
aria: retiredKey(` becomes ` aria: z.any().optional().describe(`, so the
key is accepted again. The tool reports the anchor going from 1 to 0,
the replacement from 0 to 1, and the blob from `2e0a17b14b06` to
`45e6bd9b1156`. The wrapper also arms a `trap` that restores the file.
- The control leg runs the three pin files on the committed state:
`Tests 166 passed (166)`.
- The mutant leg: `Tests 4 failed | 162 passed (166)`. The four red
tests:
  - `action.test.ts` · refuses an action carrying `aria`;
- `aria-carrier-tombstones.test.ts` · the action tombstone fires and
prescribes;
  - `aria-carrier-tombstones.test.ts` · the object-nested coordinate;
  - `action-aria-removed.test.ts` · the stored-row seam.
- The restore is proven by content, not by an exit code. The file's blob
equals HEAD's blob `2e0a17b14b06`, `git diff HEAD` is empty, and the
porcelain status has 0 lines.
- The pins import `./action.zod` and `../ui/action.zod.js` relatively.
They read `src`, not `dist`, so this ablation has no dist leg.

## Acceptance notes

- **objectui, owned by seat 4 after landing; not in this PR.**
`ActionDefaultInspector` should list `aria` in its `RETIRED_FIELDS`, per
triage note 3. `ActionRunner.ts:410` should drop its `aria?:
SpecActionInput['aria']` mirror, which evaluates to `undefined` once
this ships and still compiles (the probe above).
- **objectstack-ai#19332, owned by seat 4 after landing.** The disposition of
`action.aria` goes to objectstack-ai#19332's item that waits on this card.
- **Advisory lint.** No `lint-liveness-properties` non-warn pin is
added. The `aria` row never carried `authorWarn`, so the advisory lint's
behaviour is unchanged: it was silent before and is silent now.
- **Aliases.** `ActionSchema` never aliased `accessibility` or
`ariaProps` onto `aria`, unlike the chart config. A probe shows both
already refused as `unrecognized_keys`, so no alias refusal pin is owed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…uired key left out, or a decision branch list it cannot read — at all three doors (objectstack-ai#20316) (objectstack-ai#20416)

Fixes objectstack-ai#20316

Clause-②: no (narrowing)

A flow node config its executor cannot run is now refused where the flow
is built, at all three build doors (`FlowSchema.parse`,
`AutomationEngine.registerFlow`, `objectstack validate`), by ONE judge:
`flowNodeConfigRefusals` in `@objectstack/spec/automation`. It covers
the whole family; objectstack-ai#20317 is folded into this card:

- **a key a builtin's executor contract requires, left out** — the
census below, `loop.collection` (objectstack-ai#20317) and `map.collection` among
them;
- **a `decision` branch list its executor cannot read** — a branch with
no `label` (the card's shape), a branch that is not an object, and
`conditions` that is not an array.

## What was wrong, measured on `origin/main` `a88a1bb399` before the
change

Probe scripts drive door 1 (`FlowSchema.safeParse`, spec `dist`), door 2
(`AutomationEngine.registerFlow` with `installBuiltinNodes`, then
`execute`), and door 3 (the BUILT CLI, `node packages/cli/bin/run.js
validate --json` in a stack directory holding one
`objectstack.config.ts`). The run leg swaps the two marker nodes `y` /
`x` for a recording executor, so the route taken is observable. The
decision has out-edges `d → y` labelled `yes` and `d → x` with
`isDefault: true`.

| lit control | door 1 | door 2 | door 3 | run |
|:--|:--|:--|:--|:--|
| (a) decision branch `{ expression: 'true' }`, no `label` |
`success=true` | registered | `valid: true`, exit 0 | `success=true`,
visited `["x","y"]`: EVERY out-edge |
| (a) control: `{ label: 'yes', expression: 'true' }` | `success=true` |
registered | `valid: true`, exit 0 | `success=true`, visited `["y"]` |
| (b) `conditions: ['true']` | `success=true` | registered | `valid:
true`, exit 0 | `success=false`, `condition evaluation error: A
structural condition …` |
| (c) `loop` with `iteratorVariable` and a one-node body, no
`collection` | `success=true` | registered | `valid: true`, exit 0 |
`success=false`, `loop 'l': config does not satisfy the loop contract —
config.collection: Invalid input` |
| (c) control: the same loop with `collection: [1, 2]` | `success=true`
| registered | `valid: true`, exit 0 | `success=true`, the body ran
twice |
| (d) `map` with `flowName`, no `collection` | `success=true` |
registered | `valid: true`, exit 0 | `success=false`, `map 'm': config
does not satisfy the map contract — config.collection: Invalid input` |

Further shapes measured on the run leg only: `conditions: {}` and
`conditions: 5` fail with `conditions is not iterable`; `conditions:
'x'` fails with a condition evaluation error, because a string is
iterated character by character; a `null` branch fails with `Cannot read
properties of null`; `label: null`, `''`, `0`, `' '` and `42` all run
green down both out-edges. The last two also log the engine's
unclaimed-label warn. `conditions: null` routes like no `conditions` at
all, which is legal and stays admitted.

## After, measured on the merged branch (spec `dist` built from this
head; the CLI reads it at run time)

| lit control | door 1 | door 2 | door 3 |
|:--|:--|:--|:--|
| (a) no `label` | `custom` at `nodes.1.config.conditions.0.label` |
throws the same issue | `valid: false`, exit 1, `custom` at
`flows.0.nodes.1.config.conditions.0.label` |
| (a) control | `success=true` | registered, run visits `["y"]` |
`valid: true`, exit 0 |
| (b) `conditions: ['true']` | `custom` at `nodes.1.config.conditions.0`
| throws the same issue | `valid: false`, exit 1, same path |
| (c) loop, no `collection` | `custom` at `nodes.1.config.collection` |
throws the same issue | `valid: false`, exit 1, same path |
| (c) control | `success=true` | registered, body ran twice | `valid:
true`, exit 0 |
| (d) map, no `collection` | `custom` at `nodes.1.config.collection` |
throws the same issue | `valid: false`, exit 1, same path |

## The fix: one judge, three doors

- `packages/spec/src/automation/flow-node-config-refusals.ts` (new). It
lives beside the walk file, not inside it, because it reads the executor
contracts, and two of their modules import
`flow-node-expression-paths.ts`. A static import there closed a cycle
that read `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` mid-evaluation
(measured: `z.toJSONSchema(undefined)` in 4 spec tests).
- `getBuiltinNodeConfigContracts()` maps a node type to the very schema
its executor hands `parseNodeConfig`, plus `loop`'s parse condition (a
legacy flat-graph `loop` with no `body` is not parsed, so it stays
exempt). It is built on first use, never at module load.
  - `flowNodeConfigRefusals(nodeType, config)` has two arms:
- **Contract arm.** It parses `config ?? {}` against the contract, on
the executor's own condition, and keeps an issue ONLY where the key it
names is absent from what was authored. A present value of the wrong
type, or an undeclared key, stays where it is judged today. It also
skips issues inside an ADR-0031 region (walked as its own graph) and
inside a ledger `value` slot (`fields.*`, `assignments.*`), where a
malformed envelope is the value-envelope pass's finding. A plain
requirement gets `node-config-key-missing` with a prescription naming
the key and the node type. A requirement a rule of the contract states
gets `node-config-key-required-by-rule`, carrying the contract's own
message: a `notify` with no `template` needs a `title`, and a `lookup`
screen field needs its `reference`.
- **Decision arm.** `decision` is parsed by nothing at run time, so this
arm states what its executor reads: `conditions` present and not `null`
is an array (`decision-conditions-not-array`); every branch is an object
(`decision-branch-not-object`); every branch's `label` is a non-blank
string (`decision-branch-label-missing`, with `found`: `absent`, `null`,
`blank`, or the kind).
- `packages/spec/src/automation/flow-node-expression-paths.ts`: the five
new codes join `FLOW_SLOT_REFUSAL_CODES` and `FlowSlotRefusalParams` in
objectstack-ai#20352's shape (a stable kebab-case `code` and typed `params` beside the
message). `FlowNodeConfigRefusal` adds the `path` inside `config`. One
walk fix: an ARRAY element is no longer read as an object missing its
slot, so `conditions: [['true']]` gets one refusal (a branch that is not
an object), not also a missing `expression`.
- `packages/spec/src/automation/flow.zod.ts`: a `FlowSchema.superRefine`
block calls the judge for every node, walked with `collectFlowGraphs`,
so a node in a region body is anchored where the author wrote it. It is
a presence rule, not a key-set closure.
- `registerFlow`: no engine change. It parses first
(`canonicalizeStoredFlow` → `FlowSchema.parse`), after the ADR-0087
conversions, so the parse's issue is what it throws.
- `packages/lint/src/validate-expressions.ts`:
`validateStackExpressions` calls the same judge, for a stack handed to
it with no parse in front. One exception: a `script`'s absent `function`
stays the existing callable check's finding. That check reads the
pre-conversion spellings this pass may be handed (the `functionName`
alias, the retired dispatch keys) and names each.

**Route choices.**

1. **The judge parses the executor's contract and keeps absences. It is
not a hand-written table of required keys.** Business need: the census
is 18 keys over 13 node types, and the contract already states every one
of them, rule-required keys included. Long-term design: a table beside
the contracts would be a second statement of them, free to drift; the
ratchet below reads the executors' own parse calls instead. Guarding AI
authors: both forms refuse loudly, but this one uses the contract's own
words where the contract has them. No scope growth: keeping absence only
leaves every other contract finding exactly where it was.
2. **The label rule is "a non-blank string", wider than "the key is
absent".** Every one of those values sends the matched branch down every
out-edge (measured above), and the repo already has one notion of blank
(`NON_BLANK_STRING`). Refusing only the absent key would leave `label:
''`, which misroutes identically.
3. **A non-array `conditions` is refused as well as a non-object
branch.** It is the container of the same branch walk, and every
non-null non-array value fails the run (measured above).

## Acceptance notes

### Family census: every key a contract-parsing builtin's executor
requires

Enumerated from the code: every `parseNodeConfig(…)` call under
`service-automation/src/builtin/`, cross-checked against the
expression-path ledger's required-key reconciliation. The channels
require `loop.collection` and `map.collection`, which that ratchet had
recorded as "no door refuses their absence". It now asserts the judge
refuses both. Door status was measured by the probes above on
`a88a1bb399` (before) and on this branch (after), each row beside its
whole-config accept control, which is admitted at every door both before
and after.

| node kind | key left out | runtime reader | before: parse / register /
validate | after: all three doors |
|:--|:--|:--|:--|:--|
| `get_record` | `objectName` | `crud-nodes.ts:264` | admit / admit /
admit | refused at `config.objectName` |
| `create_record` | `objectName` | `crud-nodes.ts:335` | admit / admit /
admit | refused |
| `update_record` | `objectName` | `crud-nodes.ts:484` | admit / admit /
admit | refused |
| `delete_record` | `objectName` | `crud-nodes.ts:578` | admit / admit /
admit | refused |
| `notify` | `recipients` | `notify-node.ts:250` | admit / admit / admit
| refused |
| `notify` | `title` (no `template`; rule) | `notify-node.ts:250` |
admit / admit / admit | refused, in the contract's words |
| `http` | `url` | `http-nodes.ts:112` (parsed after interpolation;
interpolation never adds a key) | admit / admit / admit | refused |
| `screen` | `fields[i].name` | `screen-nodes.ts:158` | admit / admit /
admit | refused at `config.fields.i.name` |
| `screen` | `fields[i].options[j].value` / `.label` |
`screen-nodes.ts:158` | admit / admit / admit | refused |
| `screen` | `fields[i].reference` on a `lookup` field (rule) |
`screen-nodes.ts:158` | NOT MEASURED before (same arm) | refused, in the
contract's words (spec pin) |
| `script` | `function` | `screen-nodes.ts:321` | admit / admit / REFUSE
(the lint callable check) | refused at all three |
| `subflow` | `flowName` | `subflow-node.ts:71` | admit / admit / admit
| refused |
| `map` | `collection` | `map-node.ts:96` | admit / admit / admit |
refused |
| `map` | `flowName` | `map-node.ts:96` | admit / admit / admit |
refused |
| `loop` (with `body`) | `collection` | `loop-node.ts:85` (the no-`body`
form returns at `:72` unparsed) | admit / admit / admit | refused |
| `parallel` | `branches` | `parallel-node.ts:68` | admit / admit /
admit | refused |
| `try_catch` | `try` | `try-catch-node.ts:100` | admit / admit / admit
| refused |
| `decision` | a branch `label` (absent, `null`, blank, non-text) |
`logic-nodes.ts:48-74` (`branchLabel: cond.label`) | admit / admit /
admit | refused at `config.conditions.i.label` |
| `decision` | a non-object branch; a non-array `conditions` |
`logic-nodes.ts:48-51` | admit / admit / admit | refused |

Controls that stay admitted: a legacy `loop` with no `body` and no
`collection` (it still runs); a decision with no `conditions`,
`conditions: null` or `[]`; `assignment`, `wait` and plugin node types;
a present value of the wrong type (`objectName: 42`); an undeclared key.

Outside this census's definition (no `parseNodeConfig`), measured, **not
fixed here**: `connector_action` with no `connectorConfig` is admitted
at all three doors and refused at run (`connectorConfig.connectorId and
.actionId are required`). It belongs to the same family, but its input
is a FlowNode sibling block, like `wait`'s `waitEventConfig`, which
`FlowSchema` already requires. Reported to the seat.

### Producer census

- `examples/**` at this head: app-crm 1 flow, 8 nodes; app-showcase 30
flows, 139 nodes; app-todo 4 flows, 26 nodes. **0 refusals.**
app-multi-package declares no flows.
- `packages/**`, non-test: no default flow carries a judged node. The
Studio create seed for `flow` has `nodes: []`.
- objectui at the `.objectui-sha` pin `f8a9d0fb` (not edited):
- `FlowObjectListField` `rowsToList` drops a blank cell. So a decision
branch row with an empty **Label** cell is written as `{ expression }`,
which is now refused (`decision-branch-label-missing`). The same writer
serves the screen **Fields** repeater, so a field row with an empty
**Name** cell is written without `name`, also refused.
- `previews/flow-canvas-parts.tsx` `defaultNodeExtras` seeds a new node
with no config, or a partial one: `http` gets `{ method: 'GET' }` (no
`url`), `notify` gets `{ channels, recipients: [] }` (no `title`), and
CRUD / `script` / `subflow` / `map` / `parallel` / `try_catch` get
nothing. So a node added and saved before it is configured is now a loud
save error. The designer's live Zod pass (`clientValidation.ts` →
`FlowSchema`) will locate it at the node's config path once objectui
takes this spec.
- cloud: NOT MEASURED (no checkout in this container; the dispatch named
examples, packages and objectui).

### Fixture triage (disposition per fixture, never a batch rename)

- **Completed** (the fixture was never runnable; it now carries the key
its executor requires): skeleton flows in `spec` `flow.test.ts`,
`region-normalization.test.ts`, `flow-region-pause-and-end.test.ts`,
`api/zod-issues-to-fields.test.ts`; `service-automation`
`engine.test.ts` and 7 sibling files where `script` / `notify` / `map`
nodes stand in for mock executors; `runtime`
`automation-put-post-error-parity`, `automation-register-error-class`
and `automation-flow-clone`; `types` `validation-failure.test.ts`;
`verify` `automation-trigger-terminal-messages.test.ts`. The two
`collectFlowGraphs` scope and path pins now chain through `try` as well
as `catch`, because a `try_catch` without `try` is refused.
- **Re-judged in place** (the pin was about the executor's refusal of a
missing key, which the build door now answers first):
`config-parse.test.ts` (7), `guard-refusal-inventory.test.ts` (7 rows),
`http-nodes`, `notify-node` (2), `subflow-node`. Each now asserts the
door refusal, then registers the node WHOLE and strips the key from the
stored flow, so the executor's guard is still pinned.
`screen-nodes.test.ts`: a stored `script` carrying only retired dispatch
keys no longer registers (`leaves out function`), rather than
registering and refusing at run. `conversions.test.ts`: the flow parse
is still blind to the tombstones, and now refuses the stripped node for
its absent `function`.
- The lint's `script` callable tests are unchanged. That check keeps a
`script`'s `function`, as described above.

### Observed, not filed

- A pre-conversion alias spelling (`object`, `flow`, `functionName`,
`to` / `subject`) is refused as the canonical key's absence at a DIRECT
`FlowSchema.parse` / `defineFlow()`. `registerFlow`, `objectstack
validate` and `defineStack` convert first, so no measured producer meets
it. Carrier: whoever retires those D2 aliases at 18 (the object alias's
own docblock says it retires then); otherwise none.
- A present value of the wrong type (`objectName: 42`, `collection: ''`,
`function: ''` outside the lint) is still admitted at the build doors
and refused at run. That is the same family's type half, which this
card's direction scoped out. Reported to the seat.

## ADR-0087

- One semantic D3 entry for the family,
`flow-node-config-required-keys-refused` (major 18). It uses form D: the
decision is stated in words, with no tracker numbers in author-shown
text. `registry.ts` is regenerated.
- **No D2 conversion.** No lossless repair exists: the platform cannot
know the object, URL, collection, function or out-edge label that was
left out, and no value it could write would keep what the flow did.
- Changeset
`.changeset/20316-flow-node-config-required-keys-refused.md`:
`@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the
FROM → TO table. `service-automation` gets none, because its diff is
test files only.

## Pins: each refusal at each door, with the lit control

- `spec` `flow-node-config-required.test.ts` (`FlowSchema.parse`):
- every census row, refused at its key with the judge's own message,
beside its whole-config accept control;
  - the two rule-required keys, in the contract's own words;
  - region nesting;
- the decision arm: six label values, four non-object branches, three
non-array `conditions`;
- controls: legacy loop, wrong type, undeclared key, value slot,
`assignment` and plugin types, no-branch decisions.
- `spec` `flow-slot-refusal-codes.test.ts`: one pin per new code (code,
params, full message), the closed set split over three producers, a
sweep that reaches every node-config code, and type-level pins.
- `service-automation` `node-config-required-keys.test.ts`
(`registerFlow`):
  - the same census, and the decision arm;
- on the real executors: the refused shapes failed the run, and a
matched branch with no label ran GREEN down both out-edges while its
labelled twin took one.
- `service-automation` `builtin/node-config-contract-ledger.test.ts`:
the ratchet. The map equals every executor's `parseNodeConfig` call,
schema by identity; `loop` alone parses on a condition; every builtin
type is classified.
- `service-automation` `config-expression-ledger.test.ts`: the
cross-check described above.
- `lint` `validate-expressions.test.ts` (`validateStackExpressions`):
the same judge through the third door's pass.

## Ablation: the pins can fail

The ablation ran once, on the committed state `7f3b9b6742`, in one
`os-verify-lock` hold. It went through `scripts/ablation-replace.mjs` in
wrap mode, with the restore trapped on EXIT, INT and TERM.

- **Mutation.** `flowNodeConfigRefusals` returns `[]` unless a global
named `ABLATION_20316_OFF` is set. The anchor went from 1 hit to 0, and
the blob from `bd1d620a25b6` to `353f92191f9d`. After `pnpm --filter
@objectstack/spec build`, `ablation-dist-preflight.mjs @objectstack/spec
ABLATION_20316_OFF` found the marker in 20 built files.
- **Red, in the expected direction:**
- spec, the two pin files: 41 failed, 35 passed. Every refused row at
`FlowSchema.parse` went red, as did the five new-code pins and the
sweep. Every accept control and CONTROL block stayed green.
- service-automation, three files: 21 failed, 47 passed. Every refused
census and decision row at `registerFlow` went red, as did the ledger
cross-check. The accept controls, the run-time "what it did" pins and
the contract-ledger ratchet stayed green.
- lint `validate-expressions.test.ts`: 7 failed, 345 passed. The new
rows went red; the callable-check rows stayed green.
- **Restore leg.** The blob is back to `bd1d620a25b6`, equal to HEAD,
and `git diff HEAD` is empty. After a rebuild, `--absent` found the
marker in none of the 222 built files, and the working tree is clean.
Then spec 76/76, service-automation 68/68, lint 352/352.

## Verification

Branch head `8171d8537e`: `origin/main` `15bf186f50` merged as
`725ffa325e`, plus test-only commits after it. Every heavy run went
through `os-verify-lock`, on a closure built from the merge head (`turbo
run build --filter=@objectstack/cli...`, 59/59). The box was shared.

- **spec**, full suite, both projects: 593 files, 17196 passed, 1 todo.
Run on `725ffa325e`; no spec source changed after it.
- **service-automation**, full: 149 files, 1818 passed. Run on
`725ffa325e`; no source changed after it.
- **lint**, full: 113 files, 4712 passed, on `7f3b9b6742`.
`validate-expressions.test.ts` again on `8171d8537e`: 352/352.
- **runtime** `src/domains`: 77 files, 1436 passed, on `7f3b9b6742`.
- **Smaller suites.** types: 23 files, 692 passed. verify: 15 files, 116
passed. metadata-protocol, its 10 flow files: 207 passed.
plugin-approvals, its 5 flow files: 344 passed. cli `--project unit`,
its flow-building files: 8 files, 112 passed.
- **Typecheck** exits 0 for spec (test layer included),
service-automation, lint, runtime, types and verify.
- **Gates.** `dispatch-gates.mjs --commands` on `8171d8537e` derives 102
families; 100 exit 0. `--ran` accounts for all 102: 100 run, 2 NOT
MEASURED.
- `check:dual-build-cjs-loads`: PREREQUISITE NOT MET. 9 packages outside
this diff have no `dist/`.
- `check:type-check-debt`: its `--re-measure` runs a whole-tree build
outside the lock. This diff adds no package.
- **eslint.** `eslint --no-inline-config --format json` over this
branch's 36 changed `.ts` files: 36 files, 0 errors, 0 warnings.
- Population: `eslint.config.mjs` has `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and none of the 36 files is
ignored.
- Invariance: the config sets no `parserOptions.project`, so no linting
is type-aware, and this diff cannot move a verdict on an untouched file.
- **Declared narrowing.** Consumer packages beyond these ran on their
flow-bearing test files only. The rest of the consumer farm is CI's: cli
integration tests, dogfood, and the whole suites of runtime,
metadata-protocol and plugin-approvals.
- **Behind `main`.** The branch is 7 commits behind `origin/main`
`7db1332f19`. A merge-tree probe with no merge driver (a bare shared
clone) merges clean. `check:migration-registry` on that probe merge
reports `registry.ts` current.

## Governed surface

`skills/objectstack-automation/references/_index.md` is regenerated by
`gen:skill-refs` (byte-equal to the generator). `flow.zod.ts` now
reaches `schemaless-node-config.zod.ts` transitively, so the index gains
that one dependency row. Readings: that file 42 → 43 lines; the
published catalog's `SKILL.md` total 4402 → 4402, unchanged.

## 维护者速读(草稿)

-
**改了什么**:流程节点的配置如果缺了执行器必需的字段(比如查询节点没写对象名、循环节点没写要遍历的集合、决策分支没写标签),现在保存或校验时就会直接报错,不再等到流程运行时才失败或走错分支。
-
**为什么改**:以前这类流程能正常保存、正常发布,运行到那个节点才失败;决策分支没写标签更糟,流程会"成功"但同时走了所有分支,业务上悄悄出错。
- **风险与代价(含回滚)**:已有流程里若有这类残缺节点,升级后会被拒绝注册(启动日志会点名),需要补上字段;Studio
设计器里新加的节点在配置完成前保存会报错。回滚即撤销本 PR。
- **席位意见**:
- **你要做的**:无需操作;如需确认,关注 objectui 设计器对"空标签行、未配置新节点"的保存报错体验(另立卡跟进)。

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

---------

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/xl tests tooling

Projects

None yet

2 participants