Repository navigation
feat(spec)!: the build doors refuse an undeclared key on a script / subflow node config, with its location - #22129
Conversation
…ubflow node config (WIP) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…flow door; ADR-0087 D3 entry Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…bflow undeclared-key refusal Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4e05f7233060e5f6edb2028440e23472c889125d && git checkout 4e05f7233060e5f6edb2028440e23472c889125d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8fc50b7647d30db2a0837877cf251c1163a3239e ef0dfb44dcfce9d2a7fe2cc7a850a5a4f93a445a && git checkout -B drift-repro 8fc50b7647d30db2a0837877cf251c1163a3239e && git merge --no-ff ef0dfb44dcfce9d2a7fe2cc7a850a5a4f93a445a
node scripts/docs-audit/affected-docs.mjs --json 8fc50b7647d30db2a0837877cf251c1163a3239e
|
…hemaless-builtin-undeclared-key
…ance, comment truths - service-automation tests: the input-schema and parallel-branch doubles register under a type of their own (probe_step), never the builtin script, whose contract now refuses their undeclared keys at the flow parse. - lint: validateStackExpressions keeps its declared pre-conversion tolerance for a script node's functionName alias; every other undeclared script key is still refused there (pinned). - spec comments: the FlowSchema header and the node-config-refused-by-contract docblock say which arm judges key membership. - changeset: @objectstack/lint patch. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed from the card (#21982, body and all 7 comments: triage grade ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151) Part of objectstack-ai#22032 Clause-②: no (narrowing) This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the object's own action predicates) stays fenced, and the card stays open for it. ## What changes **The object save door gives the build's verdict on a field option's `visibleWhen`.** `formulas.mdx` says "the same `validateExpression` validator backs `os build` and metadata registration". After pass 2 the object door ran the whole field walk except the per-option loop, which kept its own guard. So an object whose option carried `visibleWhen: 'amount > 1'` (a bare field reference) still saved with a 200, while `os build` refused it at error. The server's option check cannot evaluate such a predicate and fails open (logged, allowed through), so the gate it declares is never enforced (read from `evaluateOptionVisibility` in `packages/objectql/src/validation/rule-validator.ts`). - **The lift is the guard, nothing else (H1 held).** On `origin/main` `8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] : recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`), under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now reads `recordsOf(f.options)`. On an object write the loop runs at the build's own position in the field walk, so the door gets both of the option's checks: - `check(optionWhere, opt.visibleWhen, objectName, 'record')`; - `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the refusal of a read through a reference field on `record` or `previous`. - **`current_user` keeps the build's two verdicts (H2).** An option's evaluator binds the acting user (ADR-0068 D1), so the build accepts `current_user` on an option and refuses it on the field-rule slots one level up. The door now gives both verdicts as the build does. The showcase's role gate, `'org_admin' in current_user.positions`, still saves on an option, and the same text on the field's own `visibleWhen` is refused at both doors. Both are pinned. - **No registry change (H3 re-verified).** The `validateStackExpressions` entry declares `runtimeTypes: ['flow', 'action', 'hook', 'object']` (`authoring-rules.ts`), and `runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it. `runtime-gate.ts` is untouched. - **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now names four admitted passes and one fenced pass. `AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one line that reaches a built `.d.ts`, names the per-option pass. The function-head comment and the field walk's two comments move with it. In `authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]` measurement comment, as passes 1 and 2 added theirs. Comments in four sibling test files are corrected so that none of them still says option `visibleWhen` is fenced. - **The door's verdict is the build's finding (H4).** The door's 422 issue and `runAuthoringRules('build', …)` give the same rule (`expression-invalid`), location (`object 'fx_option' · field 'province' option 'zj' visibleWhen`), path, message and hint. The pins compare these key by key. - **No code change in `packages/metadata-protocol`.** Only its test file gains the door-level pins. ## Pins - **Lint door:** `packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts` (new, 14 tests). - LIT: one refused body per finding the pass gives. These are a bare `amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown field `record.amont > 1`, a syntax error `record.country ==`, and the traversal refusal through `record.account` and through `previous.account`. Each is located at the option and asserted on its named subject. - CONTROL (the still-accepted cases): the `record.country` cascade, the showcase's `current_user.positions` role gate, a grant check plus role gate (`current_user.can(…) && …`), and a reference compared as a value (`record.account != null`). Each is clean at the door and at the build. - CONTRAST: `current_user` on the option and on the field's own `visibleWhen` in one body. The build and the door both give exactly one finding, at the field slot. - PARITY: for each refused body, the door's findings equal the build's. - The differential: a stored sibling's broken options are not this write's to answer for. - **The fence (enumeration pin)** in `packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The fenced site is now pass 4's alone (an action `visible`). The option `visibleWhen` site moves to the lifted sites, beside the validation rule and the `requiredWhen`. The build flags all four sites. The object door flags the three lifted sites in the build's order, and `runStackExpressionPasses` on an object write returns exactly the build's findings for the admitted passes. - **Protocol door:** a new pass-3 block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem`, `publishMetaItem` and `publishPackageDrafts`. - (a) A bare reference, an unregistered function and a read through a reference field are each refused on an active save. The answer is a 422 `INVALID_METADATA` carrying the build's located finding, and nothing lands. - (a) The card-shaped body is refused on a draft's promotion and on a package draft publish (`outcome: 'refused'`, `failed` naming the object with `INVALID_METADATA`, the row left a draft). The draft saves themselves still succeed. - (b) The showcase's cascade and its `current_user` role gate still save, and the row lands active. The role gate rides every refused body too, which each yield exactly one finding. - (d) For each refused body, the door and `os build` give the same finding on rule, where, path, message and hint. ## Reverse verification (one-off, from committed HEAD `23ce6c494c`) - **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put the guard back on the option loop. The new text was `const ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt] of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The anchor was hit once, 1 to 0, and the blob went `879fcb828037` to `73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on the absolute path. - **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - **Lint suites (source): 9 failed and 14 passed, as predicted.** - Red: the 6 LIT tests, PARITY, and the two fence tests that assert the lifted sites. - Green: the 4 CONTROL tests, CONTRAST, the differential, the registry test, the build-flags-each-site test and the six formula-door tests. - **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as predicted.** Red: the three (a) saves, (a) on promotion, (a) on package publish, and (d). Green: (b). - **Restore.** The tool restored the file: blob `879fcb828037` equals HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths. Lint was rebuilt, and `--absent` found the marker gone from all 14 built files. Both suites went green again: lint 23 of 23, and the protocol file 99 of 99. ## Measurements - **Corpus first: the stop condition was not met (H5).** Every object this tree ships was judged before the door changed. That is every `*.object.ts` under `packages/**` and `examples/**` (111 files) plus the two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse failures), with its own group as context. - 5 option predicates on 2 fields of 1 object, all on `showcase_cascade`: `province`'s four `record.country` cascades (`zj`, `gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in current_user.positions`). - At base `8fc50b7647`: 0 build errors and 0 build warnings for the option pass, through `validateStackExpressions` and through `runAuthoringRules('build')`, at both shapes. There were 0 door expression findings over all 118 objects. - Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare `country` and the role gate to `sqrt(record.amount) > 1`, in a copy, gave 2 build errors at those two options. - At head `23ce6c494c`, and again at the merged heads `06d3cad963` and `3c1d3262ee`: 0 door errors and 0 door advisories over every object, through `runRuntimeAuthoringRules` with type `object`, at both shapes. The harness passes each object's own group as context, so at `3c1d3262ee` every one of those saves is an update and also runs the stored-universe pass that objectstack-ai#22118 added. - Positive control in the same harness (an option `visibleWhen: 'amount > 1'`): 1 build error at every head. The door gave 0 at base and 1 at head. - **Which doors newly answer 422.** The active publish save, a draft's promotion, and a package draft publish. Each was measured through the real methods above. A draft save stays ungated, measured by the same pins. ## Clause-② (measured) - **Accept set: narrowing.** An object write in publish mode answered 200 for an option `visibleWhen` the validator refuses. It now answers 422 on the three doors above. - **Built entry declarations.** In `@objectstack/lint` one doc comment moves (`AuthoringRuleContext.runtimeWriteType`). `StackExpressionOptions` and `runStackExpressionPasses` are not in the built declarations. No exported signature moves. - **Changeset.** `.changeset/22032-object-save-door-option-visible-when.md` covers `@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING section with the remedy, and ADR-0087 `not-required (no-migration-prescription)`. `@objectstack/metadata-protocol` is listed as passes 1 and 2 listed it, although no code moves there: its save, promotion and package-publish doors are where the BREAKING behaviour can be seen. `.changeset/pre.json` is absent on `origin/main` (read at `8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the BREAKING banner, as in passes 1 and 2. The changeset says it supersedes the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged at this door. ## Merges - **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103), through `scripts/pm/os-regen-merge.sh`. It was clean. - **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents `06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`. That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133 (objectstack-ai#22118, the gate's object-write baseline keeps the written item's stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140. - `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow `script` / `subflow` region; the option loop's lift is unchanged. - One conflict: `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, one hunk. Both sides had added a new top-level `describe` block at the same place, after the pass-2 block. It was resolved by stacking: the pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of both was kept. Against `7ef50a4fbb` the file shows only this branch's lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines. - No generated path was touched by this branch. The rerun of the script took `origin/main`'s side of every generated path main moved, and that left nothing to commit. - This branch's own changes are the same before and after the merge: for each of the 9 files, the added and removed lines against the merge base are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45. - **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third, stored-universe pass on an update into a context collection. A finding whose path names no entry is judged as one on the written item. The expression rule's paths are `where` strings, so an option finding is always judged that way. - A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`) over 20 cases, against this branch's lint source before the merge (`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an update over a stored self that is broken and over one that is clean, and a stored sibling that is broken, each for three refused bodies, plus two still-accepted bodies. - The verdicts are identical, case for case. Re-saving a broken option is still refused, including over a broken stored self, because re-saving a row is writing it. A clean save over a broken stored self passes. A broken sibling is never charged. - The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three files, and the protocol file 103 of 103. - This matches the code. The option pass reads only the written object's own field index, and binds `record` and `previous`, never `parent`, so the stored universe has nothing extra to offer it. ## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main` `7ef50a4fbb`) - **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2` and touched two files this pass edits, `authoring-rules.ts` and `runtime-gate.object-writes.test.ts`, in other hunks. It was merged with `scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean, and no generated path was taken from either side. After the merge: `pnpm install --frozen-lockfile`, a full turbo build (72 tasks), and `@objectstack/spec check:generated` ("All 15 generated artifacts are up to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read (2026-10-08T02:40Z). Both have since landed, and they are merged at `3c1d3262ee` (see Merges). After that merge the same sequence ran: a full turbo build (72 tasks) and `check:generated` ("All 15 generated artifacts are up to date"). - **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck` exit 0, with its test-typecheck included (`--listFiles`: the five touched or new lint test files are in the `tsconfig.test.json` program). - **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped; 28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`: the door test file is in the program). - **Consumer readings.** Every package was built at the head named. - `@objectstack/objectql`, 8 files and 282 tests passed: `publish-package-drafts-response-conformance`, `save-meta-response-conformance`, `publish-meta-response-conformance`, `plugin.integration`, `engine-field-predicate-fault`, `engine-option-permission-predicate`, `validation/rule-validator.option-visibility` and `engine`. - `@objectstack/rest`, 11 files and 197 tests passed: every `meta-object-*` file and `meta-publish-package-scope`. - `@objectstack/cli`, 3 files and 16 tests passed, run as `--project integration` because the tier predicate puts them there: `validate-field-predicate-traversal` (which asserts an option `visibleWhen` `expression-invalid` finding), `authoring-rule-command-parity` and `verify-author-time-stage`. The three nightly-tier `*.e2e` files that assert `expression-invalid` are left to CI. - The search for other consumers covered every test file in the repository carrying `visibleWhen`, matched against the save-door entry points. Only the two packages above save an option `visibleWhen` through a door. - **Gates.** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands, the same set at `23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit 0, each exit code captured before any pipe. `--ran` reconciles 63 derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded for each. The printed artifact-roster block names 51 families, and all 51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same battery, one (`check:engine-double-contract`) already among the 63, and the three PR-scoped ones (`check-partof-closing-keyword`, `check-closing-target-claim`, `check-single-claim-paths`) run with this PR's number and this body. The same 63 also ran at `06d3cad963`, all exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0 on the first run. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full build) and exited 0 after the full build. - **ESLint, narrowed to the 8 touched TypeScript files** (`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings. Each file is matched by `eslint.config.mjs` (`--print-config`), and none was ignored. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - **Out of this pass, reported for the seat: an option `visibleWhen` reading `parent` gets no verdict at the build, so none at the door either.** - Measured at `23ce6c494c` with scratch tests that were deleted afterwards. Through the real `saveMetaItem`, an object whose option carries `visibleWhen: "parent.status == 'closed'"` saved with success, and the row landed `active`. `runAuthoringRules('build')` gave 0 findings. - The server's option check (`evaluateValidationRules`, authenticated caller, the option picked) then logged `failed to evaluate (authenticated caller) — allowed through`, with `Unknown variable: parent`, and admitted the value. The option evaluator binds `record`, `previous`, the user and permissions only. - This is a gap in the build, which the door now mirrors. This card's contract is parity with the build, so it is not changed here. - **A code comment names an old spelling.** The comment above the option loop calls the showcase's legal usage `'admin' in current_user.positions`. The showcase now writes `'org_admin' in current_user.positions`, and the pins use that spelling. The comment is not changed here: per the contract review, it rides pass 4. - **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets each list option `visibleWhen` under "Unchanged", which was true at their heads. This pass's changeset says it supersedes that line rather than editing them, the same way pass 2 left pass 1's changeset alone. Per the contract review, reconciling those lines rides pass 4. - `formulas.mdx` could name the object save door. That would be a docs addition, not a correction of a false line. - **Contract review.** Triage's grade asks for one per pass. A PASS is on record for head `06d3cad963` (`6051573476`). The head has since moved to `3c1d3262ee` by the merge above, so the review for this head is the seat's. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…iltin node types is refused at the build doors; one judge per type (objectstack-ai#22319) Fixes objectstack-ai#21982 Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) That line is the claim's, as revised by the seat answer `6063587988`, copied verbatim. The narrowing covers 10 builtins; `try_catch` stays on the descriptor walk, as the seat ruled, and is named below with its measured key difference. The widening is the save door's flow fallback (below). ## Seat answers to the build round, comment 6063587988 - **Q1: A.** On the save door's flow fallback (no canonicalizer resolved, or it threw), the gate judges the D2-converted body. The stored body stays the raw request body, exactly as before. This keeps the 6 tests in `protocol.save-flow-canonicalization.test.ts` that the build round turned red green as written. - **Q2: A.** The two spec controls that pinned 'a builtin's key membership is nobody's at the build doors' are re-pointed, test-only. Each keeps an assertion of code and location. ## What changes - **Spec (`flow-node-config-refusals.ts`).** The key arm of `flowNodeConfigRefusals` now judges key membership on every builtin in `getBuiltinNodeConfigContracts()` except `try_catch`. Before, it covered only `script` and `subflow` (pass 1, PR objectstack-ai#22129). - The new export `builtinNodeConfigKeysJudged(nodeType)` names exactly the types it judges. - An undeclared key is refused as `node-config-refused-by-contract`, `params: { nodeType, key }`, one refusal per key, anchored where the author wrote it (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). - The message carries the contract's own sentence, including its prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss). It now closes with the remedy the walk's rejection carried: rename the key to one the contract declares there, or remove it. - A body-less legacy `loop` is judged on key membership alone. Presence and values keep `parsedWhen`. - A key at or under a region slot belongs to `validateControlFlow` at registration, the same carve-out the value arm has. That covers a key on a `loop` body or a `parallel` branch, and the keys of their nodes and edges. - **`metadata-protocol` (`protocol.ts`, `domain:engine`), the save door's flow fallback.** When no flow canonicalizer resolved, or it threw, the gates judge `applyConversionsToFlow(raw body)` from `@objectstack/spec`. No `reservedNodeTypes` are passed, because no engine is on that path; the result is used for the verdict only and is never persisted. The stored body is the raw request body, as before. The canonicalized path is untouched. Two gates read that verdict body: - the schema gate (the surface the seat named); - the runtime authoring gate. This is one line, `body: flowGateVerdictBody ?? gatedItem`, declared here as a measured extension. On an active save the lint's config judge refused the raw `filters` alias there too: 5 of the 6 tests stayed red with the schema gate alone, failing at `failed author-time validation … config.filters`. The credential walk keeps the raw body. - `duplicatePackage` needs no edit. With no canonicalizer it copies the raw body (`item = body`, about `:24693`) and re-saves through `this.saveMetaItem` (about `:24779`), which reaches the same fallback. - **`service-automation` (`engine.ts`).** `validateNodeConfigKeys` stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. It keeps `try_catch` and every plugin node type. - **Ledger.** One step-18 D3 entry, `flow-builtin-node-config-undeclared-keys-refused`, with rationale order 89 (the highest on `main` at `dc4a5c630` is 88). `registry.ts` is regenerated. `api-surface/` and `export-origins/` are regenerated for the new export. - **Changeset.** `.changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md`: `@objectstack/spec` minor (BREAKING, ADR-0087 `registered`), `@objectstack/service-automation` patch, and `@objectstack/metadata-protocol` minor. It carries the revised `Clause-②` line verbatim. Pre mode is on at `main` `dc4a5c630` (`.changeset/pre.json` mode `pre`, tag `next`). The grade follows pass 1 and objectstack-ai#21898's launch-window convention for accept-set narrowings. - **Comments only (`flow.zod.ts`).** The `FlowSchema` superRefine comment (the surface the seat named), and the module header's key-membership paragraph, which the change also made false. Both are in the same file and are comment-only. ## P1: descriptor key sets against contract key sets Measured at `fbcbcf124` (tsx probe, descriptors from `installBuiltinNodes`, contracts from the spec dist): | type | descriptor walk positions and keys | contract keys at those positions | equal? | |:--|:--|:--|:--| | get_record | config {fields, filter, limit, objectName, outputVariable} | same, strict | yes | | create_record | config {fields, objectName, outputVariable} | same, strict | yes | | update_record | config {fields, filter, multi, objectName} | same, strict | yes | | delete_record | config {filter, multi, objectName} | same, strict | yes | | notify | config {actionUrl, actorId, channels, message, payload, recipients, severity, sourceId, sourceObject, template, templateData, title, topic} | same, strict | yes | | http | config {body, durable, headers, method, signingSecret, timeoutMs, url} | same, strict | yes | | screen | config (9 keys); fields[] (12 keys); fields[].options[] {label, value} | same at all three, strict | yes | | map | config {collection, flowName, indexVariable, input, itemObject, iteratorVariable, outputVariable} | same, strict | yes | | loop | config {body, collection, indexVariable, iteratorVariable, maxIterations}; body {edges, nodes} | same, strict | yes (the walk also judged a body-less loop: kept, see H2) | | parallel | config {branches}; branches[] {edges, name, nodes} | same, strict | yes | | **try_catch** | config {catch, errorVariable, retry, try}; try/catch {edges, nodes}; retry {maxRetries, backoffMs, backoffMultiplier, maxRetryDelayMs, jitter} | config/try/catch same and strict; **`retry` is `RetryPolicySchema`, a plain `z.object` that strips an unknown key**, plus the `retryDelayMs` tombstone | **no, at `retry`**: not moved | **`try_catch` (named, not moved).** Moving it would have widened registration: `retry.bogusKey` would have registered. So it stays on the walk, whole type, one judge. `os validate` and `os compile` still pass an undeclared `try_catch` key that registration refuses. The seat files the `RetryPolicySchema` strictness follow-up at landing. ## Hypotheses, measured - **H1, confirmed.** All 13 contracts answer `unrecognized_keys` at the root for `{ bogusKey: 1 }`. Nested positions are strict too, except `try_catch.retry`. - **H2, falsified as stated; route per the seat.** Today the walk refuses an undeclared key on a body-less `loop`. So the key arm judges loop key membership whatever `parsedWhen` says. Pinned: a body-less loop with `bogusKey` is still refused at `registerFlow` (`config-unknown-keys.test.ts`), so `registerFlow` widens nowhere. - **H3, confirmed.** `assignment` is in neither the contract map nor the walk. - **H4, measured.** No alias tolerance is needed in `validate-expressions.ts`. The whole lint suite had one red, a fixture writing `itemVariable` (a wrong key, not a D2 alias) on a loop with a body. Fixed in the test, and `validate-expressions.ts` is untouched. ## Registration verdicts, before and after (59-variant probe) Every variant that `registerFlow` refused before is still refused, and every one it registered still registers. - On the 10 moved types, the refusal now comes from the `FlowSchema.parse` that `registerFlow` makes first. That covers a top-level `bogusKey` on each type, the walk's 6 guidance keys, `screen` `fields[0]` and `fields[0].options[0]`, and a body-less loop's `bogusKey` / `flowName`. - Region-object keys (`loop` `body.bogusKey`) and region-node keys are refused by `validateControlFlow`, as before. - `try_catch` keys are refused by the walk, as before. - Free-form-map keys and D2 aliases (converted first) register, as before. ## Measured at the CLI doors On `examples/app-showcase`, node `notify` in `showcase_task_completed`. The mutation went through `scripts/ablation-replace.mjs` (blob `562e884310` → `87b20a9570`, restored to `562e884310` == HEAD). | door | control | `message: '{summary}' , bogusKey: 1,` | |:--|:--|:--| | `objectstack validate` | exit 0 | exit 1, path `nodes,2,config,bogusKey`, the spec refusal's text | | `objectstack compile` | exit 0, artifact without `bogusKey` | exit 2, no artifact | The first attempt used an anchor that is a substring of its replacement. `ablation-replace` refused it before running anything (anchor count 1 → 1) and restored the file, so nothing was measured on that attempt. ## Ablation At HEAD `b9a3295d1`, `builtinNodeConfigKeysJudged`'s body was replaced by `return false;` via `ablation-replace` (blob `12eb374153` → `6ab82dce15`, restored to `12eb374153` == HEAD, `git diff HEAD` empty). On `flow-builtin-node-config-keys.test.ts` plus `flow-approval-node-config-contract.test.ts`, 21 of 50 tests went red and 29 held. The spec tests import `src/`, so no dist preflight applies. ## Tests (real readings) **Patch round, at `d7466a01b`** (merged with `origin/main` `4e4111ca0` through `os-regen-merge.sh`): - `@objectstack/metadata-protocol`, whole suite: 221 files passed, 3 skipped; 28283 tests passed, 19 skipped, 0 failed. - Pre-change red set: the build round's 6 tests in `protocol.save-flow-canonicalization.test.ts` (at `b9a3295d1`), all `flow/purge_flow failed spec validation: nodes.0.config.filters`. - At `72b8d3e97`, with the schema-gate edit alone, 5 of those 6 were still red at `failed author-time validation … config.filters`. Only the draft-mode test had gone green. - At `d7466a01b` all 6 are green as written, plus the 3 new pins: 19/19 in the file. - `@objectstack/spec`, `--project local`: 626/626 files, 18732 passed, 1 todo. The two re-pointed controls (Q2) are green. - Typecheck, exit 0: `metadata-protocol` (`tsc --noEmit`) and `spec` (with `check:test-typecheck`). **Build round, unchanged by the patch** (at `b9a3295d1`; pin files re-run at `414fc860c`): - `service-automation` whole suite 176/176 files, 2163/2163. - `lint` whole suite 127/127, 5843/5843. - `runtime --project local` 336/336, 4752 passed, 19 skipped. - `trigger-record-change` 11/11, 114/114. - `dogfood` 17 flow pin files, 105/105. - Examples: showcase 408/408, todo 238/238, crm 45/45. - Typecheck exit 0: service-automation, runtime, lint. ## Ablation of the fallback edit (patch round) At `d7466a01b`, through `ablation-replace`. Each run was restored to blob `f15e4802b5` == HEAD with `git diff HEAD` empty. - **Schema gate reverted** to `schema.safeParse(request.item)` (blob `f15e4802b5` → `a0cc936d94`): 9 of 19 red in `protocol.save-flow-canonicalization.test.ts`. That is the 6 fallback tests and all 3 new pins: the `functionName` body is refused, and the `filters` + `bogusKey` bodies report two paths instead of one. - **Authoring gate reverted** to `body: gatedItem` (blob `f15e4802b5` → `19e8bbb4ed`): 5 of 19 red, the active-mode fallback tests. The new pins hold: the lint already tolerates the script `functionName` alias, and `bogusKey` is refused either way. ## Acceptance notes - `approval` has two judges: the spec arm, which judges it whole, and the walk against `getApprovalNodeConfigJsonSchema()`. `registerFlow`'s parse throws first, so the walk never decides an approval key. Carrier: none. - `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` in `engine.ts` is now unread: every type it keys is spec-judged, and each entry's prescription lives in that type's contract. It is kept and marked UNREAD, because removing it also needs the two comments in `builtin-node-config.zod.ts` that name it updated. Carrier: the next PR to touch `builtin-node-config.zod.ts`. - The `node-config-refused-by-contract` docblock in `flow-node-expression-paths.ts` still names only `script` / `subflow` for the key half. It is incomplete, not false. Carrier: the next PR to touch that file. - `objectstack validate` prints the raw issue array at 'Loading configuration…' for a refused flow. This predates the PR and was noted on pass 1. - The designer's fallback writer (`http` `outputVariable`, `notify` `url`) is tracked at objectstack-ai/objectui#11968, not changed here. - Open PR objectstack-ai#22268 also edits `packages/lint/src/validate-expressions.test.ts`. This PR changes one fixture line there (`itemVariable` → `iteratorVariable`). Whichever lands later merges `main`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run with no paths, at `d7466a01b` against merge base `4e4111ca0`, derived 96 commands. Two families joined for the `metadata-protocol` edit: `check:durability-log-level` and `check:filter-alias-parity`. All 96 were run, each exit code captured before any pipe. 95 exited 0. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (packages outside the built closure have no `dist`), so it is NOT MEASURED and CI answers it. `--ran`: `✓ dispatch-gates --ran: 96 derived famil(ies) accounted for — 95 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` `check:adr-0087-registration`: `[BREAKING+clause-②-narrowing] registered flow-builtin-node-config-undeclared-keys-refused`. eslint, narrowed (`--no-inline-config --format json`) over the diff's 20 `.ts` files: 20 files, 0 errors, 0 warnings. The population is the 20 `.ts` paths of `git diff --name-only 4e4111c HEAD`, and the file count is read from the json. Invariance: `parserOptions.project` and `projectService` are null (checked with `--print-config`), so no type-aware linting runs and no untouched file's verdict can move. ## Size 23 files, +955 / -203 vs merge base `4e4111ca0` (1158 changed lines). 0 governed paths. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #21982
Clause-②: no (narrowing)
Draft, patch round 1 done (claim revision
6050287134). This PR lands thescript/subflowhalf of the card. The card stays open for the remainder named below.What changes
The build doors now refuse a key that a
scriptorsubflownode's executor contract does not declare. They refuse it at its location, in the existing family of flow slot refusal codes.FlowSchema.parse/defineFlow(),defineStack,objectstack validate,objectstack compile, an artifact's parse, the metadata save door'sflowtype schema, andregisterFlow, which parsesFlowSchemafirst.node-config-refused-by-contract,params: { nodeType, key }. There is one refusal per undeclared key, anchored at the key (nodes.N.config.bogusKey), and its message is the contract's own sentence.packages/spec/src/automation/flow-node-config-refusals.ts, the builtin branch offlowNodeConfigRefusals. It judges key membership wherebuiltinKeysJudged(nodeType)holds. That is a builtin contract whose type is in the spec's own schemaless class,SCHEMALESS_NODE_CONFIG_SCHEMAS.configSchema, soregisterFlow's undeclared-key walk skips it. Its executor still parses the strict contract, so it refuses the node at every run.getBuiltinNodeConfigContracts()keeps its 13 entries, and no new code joinsFLOW_SLOT_REFUSAL_CODES.builtinValueJudged's docblock said "registration refuses an undeclared key against the descriptor". That was false for exactly these two types. The docblock now says which door owns which key.FlowSchemaheader inflow.zod.tsand thenode-config-refused-by-contractdocblock inflow-node-expression-paths.tsnow name each arm that judges key membership: approval whole (build: objectstack validate / compile accept unknown keys in a plugin node's config (e.g. an approval node's escalation) — the build-time refusal map covers built-in node types only #21850), builtin values (service-automation: a built-in node's config value its own contract refuses still registers, then fails every run — the built-in half of #21848's class #21898), andscript/subflowkeys.service-automationsource line moves, and there is no second key check anywhere.Built-ins: which get the key refusal, and why (measured at
15ec50e528)getBuiltinNodeConfigContractsconfigSchemascriptstrictObjectscreen-nodes.tsparseNodeConfig)subflowstrictObjectsubflow-node.tsparseNodeConfig)decisionstrictObjectconditions[]rawdecisionShapeRefusalsjudges theconditionsshape, not keys.wait,connector_actionwaitEventConfig/connectorConfig,strictObjectinsideFlowNodeSchemaconfigFlowSchemaalready refuses an unknown key in those blocks.get_record,create_record,update_record,delete_record,notify,http,screen,map,loop,parallel,try_catchstrictObject(all 11)assignmentThe remainder: the card stays open for it
Triage's direction step 2 covers every builtin whose executor contract is strict. All 13 are. This PR takes the two schemaless ones, by the seat's ruling
6050287134.configSchemabuiltins at the build doors:get_record,create_record,update_record,delete_record,notify,http,screen,map,loop,parallelandtry_catch.f281d801fonexamples/app-showcase, flowshowcase_task_completed, nodenotify, withconfig.bogusKey: 1:objectstack validateexits 0;objectstack compileexits 0, and the artifact carries"bogusKey":1;registerFlowrefuses the same node: "Flow 'p' rejected: 1 undeclared config key(s). … unknown config keybogusKeyat config.bogusKey".registerFlow? The spec arm pre-empts registration's descriptor walk, and with it that walk's pinned prescriptions (service-automationconfig-unknown-keys.test.ts).Census first (triage step 1): no writer found
scriptnodessubflownodesexamples/**,packages/platform-objects/**,packages/apps/**,packages/create-objectstack/**(templates),skills/**,content/docs/**15ec50e528c9678036d9FLOW_NODE_CONFIGa58626c88d(same file at objectuimain9990f9e122)function,inputs,outputVariableflowName,input,outputVariabletimeoutMsfield writes the node; the five retiredscriptkeys sit behind ashowWhenno field satisfies)defaultNodeExtrasa58626c88dconfigconfiga58626c88didandtype: 'script'/'subflow', reading the keys of theirconfig. Code fences in.md/.mdxand.jsonfiles were parsed too.conversions/registry.ts);linttests;service-automationengine.test.ts, repaired below.Doors, measured
objectstack validate/compile. Built CLI at round-0 headf281d801f,examples/app-showcasenodesummarize(script), one edit:function: 'summarizeCompletedTask' , bogusKey: 1,.bogusKey.bogusKey: validate exit 1, compile exit 2, and no artifact is written. Both print the refusal at pathnodes, 1, config, bogusKey.scripts/ablation-replace.mjs: anchor 1 → 0, then restored to the HEAD blob,git diff HEADempty.registerFlow(real builtin executors):script/subflowwithbogusKeyare refused atnodes.1.config.bogusKey;scriptfunctionNamealias registers: it is converted before the parse;httpbogusKeyis still refused by the descriptor walk.flow-builtin-node-config-keys.test.ts(19 tests):FlowSchema(also inside a region body),defineStack(STACK_SCHEMA_INVALID422 atflows.1.nodes.1.config.bogusKey),ObjectStackDefinitionSchema, the save door'sflowtype schema and an artifact parse;http,create_record,screen);decision; a retiredscriptkey keeps its tombstone path.builtinKeysJudgedmutated toreturn false: 12 of 19 went red and the 7 controls held. It was restored to the HEAD blob.Cross-lane fixtures repaired (claim revision
6050287134)service-automation, test only:engine.test.ts("should execute unconditional branches in parallel", "should fail when parameter type is wrong") and ininput-schema-retry-parity.test.tsnow register under the typeprobe_step, executor and nodes alike, never the builtinscript.function: 'noop'filler went with them.inputSchemareads top-level config keys, which a realscriptexecutor refuses.lint:validateStackExpressionskeeps the pre-conversion tolerance it declares.validate-expressions.tsalso hands the judge's undeclared-key refusal for ascriptnode'sfunctionNamealias to the callable check, which already reads that alias.scriptkey is still refused there (bogusKey, on a canonical and on an alias source).'@objectstack/lint': patch.Red → green. Round 0 at
f281d801fhad 4 red inservice-automationand 2 red inlint. All six now pass atef0dfb44d:engine.test.ts› "should execute unconditional branches in parallel" ✓engine.test.ts› "should fail when parameter type is wrong" ✓input-schema-retry-parity.test.ts› "never executes a node whose config mis-types its declared inputSchema — on ANY attempt" ✓input-schema-retry-parity.test.ts› "still retries a VALID flow normally …" ✓validate-expressions.test.ts› "accepts a script node that names a callable via the functionName alias" ✓validate-expressions.test.ts› "ascriptwith nofunctionis ONE finding, the callable check's …" ✓ADR-0087
18.flow-script-subflow-config-undeclared-keys-refused.ts.order: 87, re-read onorigin/main8fc50b764(the merged base): its highest order is 86, and open PRs feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) #22103 and feat(spec)!: retire the flat string-list arm of manifest.permissions — the structured ADR-0025 block is the only form (#13458) #22094 hold 86 and 85 at their heads.registry.tswas regenerated bygen:migration-registry.@objectstack/specminor, BREAKING, with theregisteredmarker, plus@objectstack/lintpatch..changeset/pre.jsonis absent onorigin/main.Merge
origin/main8fc50b764was merged byscripts/pm/os-regen-merge.shas merge commit521e16f1f, with parentsf281d801fand8fc50b764. There were no conflicts.main's side of the generated artifacts thatmainmoved, and there was nothing more to commit.check:generatedreported all 15 artifacts up to date. The delta againstmainwas exactly this PR's 5 round-0 files.gen:schemawas not run.ef0dfb44don top.Verification at
ef0dfb44dcheck:generated: all 15 artifacts up to date.flow-builtin-node-config-keys.test.tsandflow-builtin-node-config-values.test.ts: 63/63.f281d801f: 624 files, 18628 tests passed.@objectstack/spec/automationunresolvable for 17 files; it was re-run alone.@objectstack/lintexit 0 and@objectstack/service-automationexit 0, both includingcheck:test-typecheck, over a closure rebuilt with declarations.--no-inline-config --format json) over the diff's 10.tsfiles: 10 files, 0 errors, 0 warnings. The population is read from the json count.parserOptions.projectandprojectServiceare null for each file, so there is no type-aware linting and no untouched file's verdict can move.dispatch-gates --commands --repo objectstack-ai/objectstackderives 92 commands from the merged head. All ran, with exit codes captured before any pipe. The--ranreconciliation reads 91 run, 1 NOT-MEASURED, 0 UNRUN (check:dts-closurerecorded at its re-run).check:dual-build-cjs-loads: exit 3, PREREQUISITE NOT MET (packages outside this worktree's build closure have nodist). It is read from CI, as are round 0'sclipublished-subpath pins.check:dts-closurefirst exited 1, naming exactly the 19 closure packages built withOS_SKIP_DTS=1for the test runs. Re-run after the closure was rebuilt with declarations, it exits 0: 169/169 declaration files across 71 built packages.Generated by Claude Code