Repository navigation
feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location - #21974
Conversation
…xecutor contract refuses The executor-contract arm of flowNodeConfigRefusals stops being presence-only: a present value a builtin node's contract refuses is refused at parse as node-config-refused-by-contract, anchored at the key, wherever the build can know what the run parses. A value carrying a token, key membership, region slots, predicate and value ledger slots, and http's signingSecret are left to the judges that own them. Adds the D3 entry flow-builtin-node-config-values-refused (protocol 18, rationale order 85) and its BREAKING minor changeset. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…value as unjudged flow-node-config-required's presence control and the write-target arm's envelope control each asserted that a present value its contract refuses passes the parse; the value arm now refuses both under node-config-refused-by-contract, so each control states that instead. The lint fixture with a non-array screen `fields` (declared on the lint lane) now expects exactly the screen contract's refusal at config.fields. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also 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 ea7ec58e2f22c93fb209410d91714fadab5e7409 && git checkout ea7ec58e2f22c93fb209410d91714fadab5e7409
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3d9188502e1b07ae70df8b0b5e733fce44a74cfa c5545a54a61f33284db8ebddd8cb981a115cd56b && git checkout -B drift-repro 3d9188502e1b07ae70df8b0b5e733fce44a74cfa && git merge --no-ff c5545a54a61f33284db8ebddd8cb981a115cd56b
node scripts/docs-audit/affected-docs.mjs --json 3d9188502e1b07ae70df8b0b5e733fce44a74cfa
|
…iltin-node-config-values-judged
…tures that carried a value the build doors now refuse The execute-time parse tests in config-parse and notify-node now pin the door half (registration refuses the value at its key) and still reach the executor's parse the way the suite already does for a key left out: register a value the contract accepts, then write the refused one into the stored flow before the run. The "clean" advisory flows in metadata-protocol and objectql write their delete_record filter in the record form the contract declares instead of a rule array. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged
…polates before its parse
The changeset's FROM -> TO rows and the D3 entry's replacement told an
author to write a {token} template in limit or maxIterations, but
get_record and loop parse their config as authored, so such a value
passes the build doors and fails every run. A number or boolean slot
outside http now takes a literal only, http's slots keep the sole-token
form, and the "still accepted" token bullet says the hold-back is no
promise the value runs. The step-18 fragment says the same; the
registry region is regenerated.
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…registration refuses, the executor refuses past the doors Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…tmpl envelope (objectstack-ai#22063) Fixes objectstack-ai#22054 Clause-②: yes (narrowing: the template envelope is newly accepted, and a blank or whitespace bare string is newly refused at `title` / `message`, with `NotifyConfigParsed` re-shaped; a BREAKING accept-set narrowing, `@objectstack/spec` `minor` under the launch-window convention, per contract review `6033083158`) ## What changed The expression dialect table in `packages/spec/src/shared/expression.zod.ts` lists notification subjects and bodies as `template` slots. `NotifyConfigSchema.title` and `NotifyConfigSchema.message` were `z.string()`, so a notify node written with `` tmpl`…` `` was refused. This PR follows the triage direction (the first of the card's two): - **Spec (`packages/spec/src/automation/io-node-config.zod.ts`).** `title` and `message` are typed `TemplateExpressionInputSchema.optional()`, the input every other `template` slot uses. Both the bare string and the `{ dialect: 'template', source }` envelope parse. The parse normalizes the bare string to that envelope, so both spellings of one text parse to the same value. - The mutual-exclusion rule (`template` against `title`/`message`), the `templateData` rule and the "needs a content source" rule are unchanged. - One rule is added, for this slot only. A template envelope on either key must carry a non-blank `source`. The shared input's envelope arm is the persistence contract and admits an `ast`-only envelope or a whitespace `source`. The executor renders `source` only, so without this rule such a `title` would fail every run and such a `message` would go out empty. - **Executor (`packages/services/service-automation/src/builtin/notify-node.ts`, declared cross-lane file).** The executor reads `cfg.title?.source` and `cfg.message?.source` and interpolates them exactly as before. Before this change it read the slot whole: `interpolate` walks an object key by key, and `stringifyForTemplate` then serialized it as JSON (H1 reading below). The descriptor's `title`/`message` descriptions now state the `{token}` interpolation instead of "sent verbatim". The descriptor keeps `type: 'string'`: the Studio form edits the bare-string spelling. - **Every published sentence about the two keys is now true.** The `.describe()` texts, the schema docblock and the conflict-refusal text said the text is "sent verbatim". The executor interpolates it, so that sentence was already false before this PR. They now say which placeholder spelling the slot's renderer reads: the flow's single-brace `{token}`. - Generated: `content/docs/references/automation/io-node-config.mdx` (`gen:docs`). No other spec artifact moved. `check:generated` reports all 15 up to date. ## Measurements **H1: what the executor did with an envelope.** Measured with `interpolate` and `stringifyForTemplate` from `template.ts`, with `record = { priority: 'P1', subject: 'Server down' }`: | input | before (base `d5a14dd5`) | after | |:---|:---|:---| | bare `'[{record.priority}] {record.subject}'` | `[P1] Server down` | `[P1] Server down` (unchanged) | | `` tmpl`[{record.priority}] {record.subject}` `` through `interpolate` + `stringifyForTemplate` | `{"dialect":"template","source":"[P1] Server down"}` | not reached: the executor reads `source` | | same envelope, through the whole node | refused at the execute-time parse: `config.title: Invalid input: expected string, received object` | delivered `[P1] Server down` | Ablation of the executor read, with the new spec and the old read `interpolate(cfg.title ?? '', …)`: all three render pins in `notify-template-slots.test.ts` go red. The bare string goes red too, because the parse now hands the executor an envelope for both spellings. The delivered title was `{"dialect":"template","source":"[won] Deal Acme"}`. The restore was verified (blob equal to `HEAD`, `git diff HEAD` empty). **Premise check.** The card says `defineFlow` refuses the envelope. On `main` at `d5a14dd5` it does not. `FlowSchema.safeParse` and `defineFlow` accept a notify node with a `tmpl` title, and `AutomationEngine.registerFlow` registers it. The refusal comes only at execute time, from `parseNodeConfig` (`config.title: Invalid input: expected string, received object`). The flow builds and registers, then fails every run. The card's core premise holds: the schema disagrees with the dialect table. **Pins** (spec `io-node-config.test.ts`, executor `notify-template-slots.test.ts`): | value at `title` / `message` | parse | render | |:---|:---|:---| | `'[{stage}] Deal {dealName}'` (bare) | ok, normalized to `{ dialect: 'template', source }` | `[won] Deal Acme` | | `` tmpl`[{stage}] Deal {dealName}` `` / `{ dialect: 'template', source }` | ok, same value | `[won] Deal Acme` (same text) | | `42`, `true`, `['a']`, `{ source }`, `{ dialect: 'cel', source }` | refused, one issue at the key: `invalid_union`, message equal to `TYPED_EXPRESSION_DIALECT_ONLY.template` | node refused before anything is sent | | `''`, `' '` | refused: `invalid_union`, message equal to `TYPED_EXPRESSION_SOURCE_REQUIRED.template` | n/a | | `{ dialect: 'template', ast: … }`, `{ dialect: 'template', source: ' ' }` | refused: `custom` at the key, message naming `source` | n/a | Reverse runs. The new spec pins were run against the base schema file (restored from `d5a14dd5`, trap-restored, blob verified). Result: 7 red (the 6 new pins and the updated "accepts every declared key"), 27 green. Disabling only the new `source` rule turns exactly 1 pin red. A cross-package type check: writing `cfg.title?.trim()` in the executor makes `tsc` red with `TS2339 … on type '{ dialect: "template"; source: string; } | …'`, so `service-automation` reads the rebuilt `.d.ts`. **H3: the `subject` alias conversion.** No change is needed. - ``subject: 'X'`` alongside ``title: tmpl`X` `` are structurally different values, so `flow-node-notify-config-aliases` keeps both. The strict gate then refuses `subject` with its guidance. - `subject` alone, as a bare string or an envelope, still renames onto `title` and parses. - The guidance's "with DIFFERENT text" is now "with a DIFFERENT value", which is true in this case as well. - Nothing is lost silently. No stored pre-17 flow can carry the pair, because an envelope `title` never parsed before this PR. **H5: the expression-slot machinery.** Nothing new sees these keys as expression slots. - They are not on `FLOW_NODE_EXPRESSION_PATHS`, so the lint `validateExpression` walk and the registration expression pass do not visit them. - The generic `{token}` path walk (`validate-flow-template-paths`) recurses into objects, so it reads an envelope's `source` as it read the bare string. - `authorable-surface`, `liveness` and the strictness ledger record keys, and the key set is unchanged. All three gates are green with no artifact moved. - `check:api-surface` is green with no artifact change. ## Acceptance notes - **Placeholder spelling.** These two slots render through the flow's `interpolate()`, so the placeholder is `{record.name}`. A `{{record.name}}` renders with its outer braces left in (`{Acme}`), for a bare string and an envelope alike. That was already true for bare strings. The `.describe()` texts now say it. - The card's own example (`` tmpl`[{{record.priority}}] {{record.subject}}` ``) now parses and renders `[{P1}] {Server down}`. - Three shared texts outside this PR's surface still show `{{var}}` as the spelling to write: the shared template refusals `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template`, and the `tmpl` docblock. This is reported to the seat; it is not changed here. - **Blank strings: a BREAKING accept-set narrowing.** A blank bare string (`''` or whitespace-only) at either key was accepted on `main` and is now refused, by the shared template input's non-blank rule. - `title: ''` used to parse and then fail every run with "notify: title is required", so it fails either way, now earlier. - A whitespace-only `title` passed that guard and was delivered. It is now refused. - A blank or whitespace-only `message` was delivered as an empty or blank body. It is now refused. - Measured: zero blank notify `title`/`message` values in the 31 in-repo authoring files and at the objectui pin. objectui's flow inspector deletes a cleared key (`setAtPath`) only for `''`, so a whitespace-only Studio entry is stored. - Graded as the repo graded the same rule in `f81afe3`: `feat(spec)!`, `Clause-②: yes (narrowing)`, an ADR-0087 `not-required (no-migration-prescription)` marker with this census, and a **BREAKING** line, shipped as `minor` under the launch-window convention (contract review `6033083158`; patch round 1, `9bfb746a35`). - The parse output also changes: `NotifyConfigSchema.parse(...).title` / `.message` go from a string to `{ dialect: 'template', source }`, and `NotifyConfigParsed` with them. The notify executor, the one reader of parse output in this repo, reads `.source`. - **Studio form.** The descriptor keeps `type: 'string'` for both keys, so the Studio form authors the bare string. objectui's `FlowNodeConfigField` renders a text control with `String(value)`, so a code-authored envelope would display as `[object Object]` there. That is outside this repo and is noted for the objectui owner. - **Not merged with `main`.** `origin/main` gained 2 commits since `d5a14dd5` (`packages/spec/src/ui/**` and `metadata-protocol`). They share no file with this diff. - PR objectstack-ai#21974 also edits `notify-node.test.ts`. The new executor pins live in their own file, `notify-template-slots.test.ts`, so the two PRs do not conflict there. ## Local verification (final commit `01ef8368e5`) Every reading below was taken on this branch. `packages/spec` is byte-identical from `ed7166a5e2` to the final commit `01ef8368e5`. The only later change is one line in `notify-template-slots.test.ts`. - `pnpm --filter @objectstack/spec build` (JS and DTS): exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15 artifacts up to date. `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:liveness` and `check:strictness-ledger` are among them. - `pnpm --filter @objectstack/spec test`: 620 files, 18497 passed, 1 todo, exit 0. Run at `ed7166a5e2`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` holds its ledger unchanged. - `pnpm --filter @objectstack/service-automation test`: 174 files, 2116 passed, exit 0. Run at `01ef8368e5`. - `pnpm --filter @objectstack/service-automation typecheck`: exit 0. - `pnpm --filter @objectstack/lint test`: 120 files, 5638 passed, exit 0. Run at `01ef8368e5`. - `node scripts/pm/dispatch-gates.mjs --commands`: 111 families, derived with no paths at `01ef8368e5`. All were run and reconciled with `--ran`: 110 run, 1 NOT MEASURED, 0 unrun. - NOT MEASURED, reason: `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package's `dist/`). It is declared to CI. - `check:skill-examples` first exited 3 because the client packages had no `dist/`. After building `@objectstack/client` and `@objectstack/client-react` it exited 0 (262 examples type-check). - ESLint, narrowed to the 4 changed TS files and run at `01ef8368e5` with `--no-inline-config --format json`. - Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (the `.md`/`.mdx` files in this diff are outside it). - Count: 4 files, 0 errors, 0 warnings, read from the JSON output. - Invariance: the config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot change any untouched file's verdict. - The repo-wide `pnpm lint` is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…safe master-reference shapes at error on a controlled_by_parent object (objectstack-ai#22109) Fixes objectstack-ai#9139 Clause-②: no ## What this does `relationship/master-detail-required` (R2 in `packages/lint/src/data-model-rules.ts`) now has two tiers under one rule id, as the maintainer ruling of 2026-08-16 (Direction 1, scheduled for the v18 boundary) orders. - **On an object with `sharingModel: 'controlled_by_parent'`**, every `master_detail` field is refused at `error` in each of the three unsafe shapes: `required` absent (or `false`); `required: true` + `readonly: true`; `required: true` + `system: true`. One finding per field, located at the first defect (`.required`, `.readonly` or `.system`); the `fix` names every edit the field needs. - **On every other object** the verdict is unchanged: a `warning` for a `master_detail` without `required: true`, with the same message and fix, and silence on the two flagged shapes. Before this change the predicate was `required !== true` at `warning` on every object. Two of the three shapes drew no finding at any severity, as the census note on objectstack-ai#9139 measured. A one-line severity flip would have closed one shape and left two open, so the predicate covers all three. Each shape is pinned on its own. Runtime is untouched, as the card's scope requires: `resolveCbpRelation`'s fallbacks and the security gate stay as they are. ## The mechanism hypotheses, measured | # | hypothesis | reading | |---|---|---| | H1 | R2 at `data-model-rules.ts:742`, `warning`, `required !== true`, every object | Holds at `b04a5295f`. Before-probe: CBP and non-CBP alike gave `warning` for absent / `false`, and nothing for `+readonly` / `+system`. | | H2 | the `:817` pin filters to `SECURITY_CBP_NO_RELATION`, so promoting R2 leaves it green untouched | **Holds.** The pin asserts only that the CBP no-relation rule (a mirror of `resolveCbpRelation`) is silent. That stays true, because the runtime still resolves step 2. The pin is **reversed, not removed**: see the pin section below. | | H3 | R2 bites `os lint` and the eval rubric only | **Holds.** `lintDataModel` is called from `lintConfig` (`os lint`, exit 1 on any error) and from `scoreMetadata` (the generation rubric: `valid` requires zero errors). It is not an `AUTHORING_RULES` entry: `authoring-rule-wiring.test.ts` pins `lintDataModel` in `DIRECT_CALL_RATCHET` as lint-only. So `os build`, `os validate` and the metadata save door do not run it. `check:i18n-coverage` spawns `os lint` but tolerates a non-zero exit. Registration was **not** widened; see the open question in the report. | | H4 | objectstack-ai#9138 (builder force) landed | **Holds.** `forceCbpMasterDetailRequired` (`object.zod.ts`) forces an omitted `required` to `true` under CBP and refuses an explicit `false`. It skips the array field form and never inspects `readonly` / `system`. So through `ObjectSchema.create` only shapes 2 and 3 reach the lint, and both pass the builder untouched. Shape 1 reaches the lint from anything not built through `create`: a plain object literal, the array form, or raw parse of stored metadata. Pinned with real `ObjectSchema.create` objects. | | H5 | 0 in-tree corpora turn red | **Holds.** Census below. | | H6 | take the next free order in the step-18 chain | **Falsified as stated.** There is no order to take: entries are one file each under `entries/semantic/`, and `gen:migration-registry` sorts the registry by id. The new entry is `cbp-master-detail-required-lint-error`. Whichever of this PR, objectstack-ai#22094 and objectstack-ai#21974 lands later merges `main` and re-runs the generator. Per the entries README's measured table, a driver-less merge conflicts only when two new ids are adjacent in sort order. Whether that holds against those two PRs' ids is NOT MEASURED. | ## The step-2 pin: reversed, not removed `validate-security-posture.test.ts` "stays silent on step 2: ANY master_detail (not marked required)" pinned the step-2 shape as supported. The ruling retires that reading as a deliberate contract narrowing. Under H2 the pin's assertion is about the runtime mirror, and that half stays true, so the pin now asserts **both halves on one stack**: 1. the CBP no-relation rule stays silent: `resolveCbpRelation` still resolves a non-required `master_detail`, and metadata at rest keeps loading; 2. the same declaration draws `relationship/master-detail-required` at `error` from `lintDataModel`. The pin's title and comment state the narrowing. The pin is kept rather than deleted, so a later change that re-tolerates the shape at authoring time, or drops the runtime tolerance, turns it red. ## Census (H5), at `b04a5295f` plus this change Every `*.object.ts` outside tests and fixtures (111 files, 0 import failures), plus the CLI's golden eval corpus and the multi-package example's two sub-stacks. Each corpus was linted with its own controls appended to its own array: one positive control per unsafe shape and a clean negative control. | corpus | objects | `controlled_by_parent` | R2 error | R2 warning | controls | |---|---:|---:|---:|---:|---| | examples/app-crm | 6 | 1 | 0 | 0 | 3 reached, neg clean | | examples/app-showcase | 22 | 2 | 0 | 0 | 3 reached, neg clean | | examples/app-showcase (external sub-stack) | 2 | 0 | 0 | 0 | 3 reached, neg clean | | examples/app-todo | 1 | 0 | 0 | 0 | 3 reached, neg clean | | examples/app-multi-package (core, orders) | 2 | 0 | 0 | 0 | 3 reached, neg clean | | packages/cli DEFAULT_METADATA_EVAL_CORPUS | 11 | 4 | 0 | 0 | 3 reached, neg clean | | packages/platform-objects | 46 | 0 | 0 | 0 | 3 reached, neg clean | | packages/plugins, services, metadata-core, qa, create-objectstack | 39 | 0 | 0 | 0 | 3 reached, neg clean | | **total** | **129** | **7** | **0** | **0** | | No stored in-tree metadata turns red. ## v18 upgrade-checklist line (for the v18 release notes) > On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. The changeset's Remedy section carries this line verbatim. The step-18 semantic entry `cbp-master-detail-required-lint-error` carries the same prescription for `os migrate meta`. This PR does not touch `content/docs/releases/**`. ## Release grading `.changeset/pre.json` is absent on `origin/main`: read at `b04a5295f` (2026-10-07T15:37Z) and again at `bafb58bb0` before this push. So the changeset grades `@objectstack/lint` and `@objectstack/spec` at `minor`, with the BREAKING banner, `Clause-②: no (narrowing)` in its body, and the ADR-0087 disposition `registered cbp-master-detail-required-lint-error`. ## Files - `packages/lint/src/data-model-rules.ts`: R2's CBP tier (`cbpMasterReferenceFinding`), placed above the first exported rule. `authoring-rule-wiring.test.ts` reads a rule body as the source text up to the next `export`, so an error-emitting helper placed below `lintLegacyOrganizationComposites` was read as that advisory rule emitting `error`. That was measured red once, then fixed by moving the helper. - `packages/lint/src/data-model-rules.master-detail-required.test.ts` (new): 19 cases. Each unsafe shape at `error` with its path; two clean controls; one finding for several defects; every `master_detail` field; the array field form; a lookup ignored; non-CBP unchanged across 4 sharing models plus the two flagged shapes; and real `ObjectSchema.create` objects. - `packages/lint/src/validate-security-posture.test.ts`: the step-2 pin, reversed as described above. - `packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts` and the regenerated `registry.ts`. The entry carries no tracker id and no call spellings. `spec-changes.json` and the upgrade guide do not project step 18 yet, and `check:generated` reports all 15 artifacts current. - `packages/cli/test/score.test.ts`: fixture triage. The "warning" fixture of "suggestions cost less than warnings cost less than errors" was a CBP object, so it now measured the error weight. Probe: `counts.errors` 1, `valid: false`, with R2 at `error`. Its assertions still passed, but for a different reason. The fixture moves to `sharingModel: 'private'` and two assertions pin it to the warning tier. - `content/docs/protocol/kernel/error-handling.mdx`: this change made one sentence false ("does not report the `readonly`, `system` … shapes at all"). It now names `os lint`'s error tier and its reach. - `.changeset/9139-cbp-master-detail-required-error.md`. ## Verification (final head `bee9c1e25`) All readings below were taken at `bee9c1e25`, after the last commit. Each exit code was captured before any pipe. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 113 commands. All 113 ran at `bee9c1e25`: **112 exit 0, 1 exit 3**. The exit 3 is `node scripts/check-plugin-teardown-shape.mjs --self-test` refusing its own prerequisite, because its positive control is pinned to commit `621a4876` outside this shallow clone. That is NOT MEASURED, not a finding; CI runs it on a full checkout. Reconciled with `--ran`: "113 derived famil(ies) accounted for — 112 run, 1 NOT-MEASURED, 0 UNRUN". Gate lines quoted from the run: - `check-adr-0087-registration`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] registered cbp-master-detail-required-lint-error (new here: …)" - `check:migration-registry`: "registry.ts is current (382 semantic, 247 retired-key, 218 retired-def)" - `check-issue-citations`: "every citation this change adds resolves" - `check:nul-bytes`: "OK … no raw ASCII control bytes" - `check:doc-authoring`: "17816 customer-facing string(s) … clean" - **`@objectstack/lint`:** `pnpm --filter @objectstack/lint test` passed 122 files / 5665 tests. `pnpm --filter @objectstack/lint typecheck` was green; "check:test-typecheck: OK". - **`@objectstack/spec`:** `vitest run src/migrations scripts/build-migration-registry-entry.test.ts scripts/step18-rationale-merge.test.ts` passed 5 files / 191 tests. `pnpm --filter @objectstack/spec check:generated` was "All 15 generated artifacts are up to date". That run was on top of `3f6d9ca97` (the registry regeneration); `packages/spec` has not changed since, and its per-artifact gates are in the 113 above. - **`@objectstack/cli` (consumer that reads R2 findings):** `vitest run --project unit` passed 259 files / 3795 tests. `pnpm --filter @objectstack/cli typecheck` was green. The `integration` tier is declared to CI; `migrate-meta-engine-guidance.test.ts`, which holds every semantic entry's printed prose, lives there. - **ESLint, a proven narrowing.** `eslint --no-inline-config --format json` over the 6 touched `.ts` files counted 6 files, 0 errors and 0 warnings. `--print-config` resolves all 6 inside the config. The `.md` / `.mdx` files answer "File ignored because no matching configuration was supplied". The config enables no type-aware linting (`parserOptions` is `{ ecmaVersion, sourceType }`, with no `project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. **Reverse verification.** With `data-model-rules.ts` reverted to `b04a5295f` and the tests run from the committed state, **10 failed / 142 passed**. The failures: all 4 CBP shape cases, several-defects, every-field, array form, the 2 `ObjectSchema.create` refusals, and the reversed step-2 pin. The controls and every non-CBP case stayed green, as expected. The file was restored with `git checkout HEAD -- …`, and the restore was proven by blob hash equal to HEAD's and an empty `git diff HEAD`. ## Acceptance notes (not changed here) - `skills/objectstack-data/references/lint-rules.md:13` lists this rule's severity as `warning`. That is now true only off `controlled_by_parent`. `skills/**` is a Tier H governed surface outside this claim's file surface, so it is left for the seat (see the report's open question). - `packages/plugins/plugin-security/src/security-plugin.ts`, the paragraph above the freeze note: "Direction 1 … has NOT landed … nothing warns on the way past". It says itself that it "goes stale when objectstack-ai#9139 lands". The dispatch fences the runtime package, so it is left for the seat. - The `object.zod.ts` docblock of `forceCbpMasterDetailRequired`, and the sibling entry `cbp-master-detail-required-forced`, say the lint "stays `warning` until v18". That is still accurate as a schedule, so neither was edited. - The census's earlier global red (`showcase_field_zoo.f_master_detail`) was already fixed on `main` (`required: true`). The global warning count is now 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21898
Clause-②: yes (narrowing)
Merge gate (the ruling's timing): this PR merges only once
.objectui-shaonmainis at or after5ba255538a(objectui#11670, the Studio designer storing a screen field's Min / Max as numbers). At this base the pin is0abd4f9f87, which does not carry it. This PR does not move the pin.At landing: both gates are met.
.objectui-shareadsa58626c88d, which contains5ba255538a.maincame in by two pure merges,42ce99cf91and00bf19bdb5. Each tree equals itsgit merge-tree.One test commit,
c5545a54a6, adds a 16th file:packages/services/service-automation/src/builtin/notify-template-slots.test.ts.title: 42case came with feat(spec): notify title/message are template slots — bare string or tmpl envelope #22063. It now asserts the registration refusal, then the executor's refusal past the doors (claim revision6040112148).6041691539onc5545a54a6.Draft. Patch round 1 re-judges the cross-lane fixtures that the claim revision
6012822762declared (#6021 comment6012831917; #6367 comment6012842570). See "Cross-lane fixtures re-judged" below. No source line moved in any of those packages.The build doors now refuse a value that a builtin node's executor contract refuses, with its location, at
FlowSchema.parse,objectstack validateandobjectstack compile. Ruling6010677104(A) gives the pin, the measured pair:create_recordwithconfig.outputVariable: 42, and a screen field with a stringmin. Each is refused at save with its location. Until now both passed every build door and registered, and then every run that reached the node failed at the executor's contract parse.What changes
packages/spec/src/automation/flow-node-config-refusals.ts: the builtin executor-contract arm offlowNodeConfigRefusalsis no longer presence-only. A contract issue at a key the author wrote is now refused with the existing closed-set codenode-config-refused-by-contract,params: { nodeType, key }, anchored at the key. It is the same code, and the same message builder, the approval contract uses. No new code joinsFLOW_SLOT_REFUSAL_CODES.The refusal is kept only where the build knows what the run will parse (
builtinValueJudged). Each carve-out sits where another judge owns the finding, or where the run may parse something other than what was authored:unrecognized_keys) and a tombstoned one (aretiredKey(),invalid_typeexpectingnever). Registration refuses an undeclared key against the descriptor, with its own prescriptions. The lint names the retired script keys. The D2 layer rewrites retired spellings first at the two doors that convert.try: 5, a one-branchparallel). Region shape belongs tovalidateControlFlow; region nodes are judged as graphs of their own.predicateorvalueledger slot, at or inside it: a screen field'svisibleWhen, and a CRUDfieldsvalue.predicateSlotRefusaljudges the first (its non-strings are left toregisterFlowandvalidateby ruling, per theflow.zod.tsnote). The value-envelope pass judges the second.http.signingSecret. A secret held in the credential channel replaces the literal before the parse.{token}anywhere inside it. It is never refused for its pre-interpolation type. The pattern is the interpolator's own, which also matches the double-brace and dollar-brace spellings.getBuiltinNodeConfigContracts()keeps its export, its shape and its 13 entries, and the lazy-build cycle note stands. The approval arm is unchanged and still judged whole. The docblocks that said "presence-only" moved: the module header, the judge's docblock,absentAt, two blocks inflow.zod.ts, and the approval test's control title and header.Built-ins not judged whole, each with its reason
http: its executor parses after interpolating the whole config. So a value is judged only when nothing inside it carries a token (interpolation is then the identity). A rule is judged only when the whole config carries none.signingSecretis never judged.loop: its executor parses only when there is abody(parsedWhen). A legacy flat-graph loop is judged for nothing. A loop with a body is judged oncollection,iteratorVariable,indexVariableandmaxIterations.loop(body),parallel(branches) andtry_catch(try,catch): they hold regions, which are judged as graphs of their own. Each container is judged on the keys beside its regions, such astry_catch'serrorVariableandretry.parallelhas only its region slot, so it is judged for presence alone.Every other builtin is judged on every present value:
get_record,create_record,update_record,delete_record,notify,screen,script,subflowandmap.Census (round 1, report
6006631317)The census took every builtin node
configat833d57c9cf. For each it listed what the planned arm refuses, using the arm as its predicate. A lit control (planted file) found all 6 planted refusals and exempted both planted tokens.packages/qa. None is refused. 190 template strings are counted separately, all in string-typed slots.4054ec2680: 138 nodes, none refused.After this change I re-ran the census with the implemented judge in place of the predicate. On every statically evaluable node it agrees with the predicate. The only differences are values the walker cannot evaluate.
Census, extended in patch round 1 to helper calls, same-file consts and property assignments. The walker now also reads three shapes:
f(…, 'TYPE', …, { … }), taking the first object after the type as the config;….configor….config.KEY.A lit control found a helper-call refusal, a const-resolved refusal and a rule-array assignment, and passed a token.
d1c7d8d392: 1065 configs (839 literal nodes, 224 helper calls, 2 JSON), plus 70 assignment sites. The judge refuses static values in 27 rows. Each is one of four things:lintFlowCredentialLiterals,validateFlowNodeWrites,lintFlowPatterns,resolveFlowNodeExpressions), green;4054ec2680: 138 configs, 0 refused.configFor(type, …)) or a loop over a sweep;idorlabel;Reproduction, before and after (a scratch copy of
examples/app-showcase, removed afterwards)833d57c9cf(base)create_taskoutputVariable: 42✓ Validation passed) · compile 0, artifact carries42customatnodes.1.config.outputVariable, no artifactmin: '1'customatnodes.1.config.fields.0.minget_recordlimit: '{inquiry_cap}'Every edit was proved on disk with
grep -c, anchor 1→0 and injected 0→1. The validate door now reads: "Thiscreate_recordnode's config is refused atoutputVariableby the create_record contract: Invalid input: expected string, received number. Its executor parses the config against that contract before it does anything else and refuses the node on any finding, …".At base, at the engine (built
service-automation):outputVariable: 42registers, then runs 1 and 2 each fail withcreate_record 'mk': config does not satisfy the create_record contract — config.outputVariable: …and 0 inserts. The designer-shaped screen node registers, then its run fails atconfig.fields[0].min.Pins (
flow-builtin-node-config-values.test.ts, 44 tests)FlowSchema(customatnodes.1.config.outputVariableand atnodes.1.config.fields.0.min), with the judge's code, params and path. It is also refused inside aloopbody, atnodes.1.config.body.nodes.0.config.outputVariable.limit,multi,severity, anotifyrule,timeoutMs,durable,headers.X-Kind,mode,fields[0].required, an emptyfunction/flowName,collection,maxIterations,errorVariable,retry.maxRetriesand others.defineStack(STACK_SCHEMA_INVALID/ 422 atflows.1.nodes.1.config.outputVariable),ObjectStackDefinitionSchema(both pins), the registeredflowtype schema the save door uses (the screen pin) and the artifact parse. The CLI doors are in the table above.Ablation (reverse verification). I committed first, then used
scripts/ablation-replace.mjsto restore the presence-only line (if (!absent && !whole) continue;): anchor 1→0, blob01e47e2d7e35→2d67f51da4c4. The subject resolves throughsrcby relative import, so no build was needed.flow-node-config-required.test.ts, 3 inflow-write-node-stored-metadata-target.test.ts).Tests 40 failed | 129 passed (169), 36 / 1 / 3 as predicted.git diff HEADempty.A second ablation deleted the token exemption line. Predicted 1 red (the token control); observed
Tests 1 failed | 70 passed (71), then restored the same way.The ADR-0087 kit
entries/semantic/18.flow-builtin-node-config-values-refused.ts. Itsregistry.tsregion was regenerated bygen:migration-registry.origin/mainat230e4944b0just before opening: the highest order there is 84, and this id is absent.minorfor@objectstack/spec, with theregisteredmarker and theClause-②line.check-adr-0087-registrationpasses.check:generatedreports all 15 artifacts up to date. The public exports did not change.Fixtures re-judged in this PR
spec/.../flow-node-config-required.test.ts: a control pinned "a present wrong-typed value is not refused". That is exactly the branch removed, so the control is replaced. It now asserts the value arm's code, and that this rule still reports absence only.spec/.../flow-write-node-stored-metadata-target.test.ts: the "dynamic objectName" control included an expression envelope inobjectName. The CRUD contract declaresobjectNamea string, so the run refused that envelope too. The template cases keep their control. The envelope now asserts exactly the value arm's refusal, and still none from the write-target arm.spec/.../flow-approval-node-config-contract.test.ts: only the builtin control's title and header wording; the assertion is unchanged.lint/src/validate-expressions.test.ts:2360, declared on [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_012yK1ddAdAqfzwigRHZq3r2 #6023 in comment6011223490: the screenfields = 'nope'fixture now expects exactly the screen contract's refusal atconfig.fields. Nopackages/lintsource line moved.Cross-lane fixtures re-judged (patch round 1, declared on their lanes)
Each of these fixtures registered or saved a flow carrying a value its contract refuses, so every one of those flows also failed at its first run. The round-1 census missed them because each config arrives through a helper argument or a property assignment. The step-7 suites caught them.
service-automation,config-parse.test.ts(the tests formerly at:118,:129,:184,:193and:317) andnotify-node.test.ts(formerly:274). Each test keeps the subject its title names: the executor's execute-time parse.runPatchedbeside the existingrunStripped; innotify-node, it is the stored-flow edit its "no recipient" test already uses.limit,timeoutMs,mode,flowNameandtemplate.service-automationsource line moved.metadata-protocolprotocol-publish-drafts-advisories.test.ts:205, andobjectqlpublish-meta-response-conformance.test.ts:413andsave-meta-response-conformance.test.ts:298. The "clean" flow'sdelete_recordfiltermoves from the rule array to the record form the contract declares:{ created_at: { $lt: '2020-01-01' } }.flow-multi-write-unfiltered,lint-flow-patterns.tsfilterCarriesNoCondition) stays silent. Measured:reduceFilterVerdictanswers'clause'for the record form, so a condition is written.filter, which the contract refuses outright.filteras a rule array.0abd4f9f87: the designer maps the slot (descriptortype: 'object', additionalProperties: true) to itskeyValuewidget, which commits a record. It keeps an array only when an array is already stored, and then in its{ variable, value }form; it never writes a rule array.packages/qa) and hotcrm4054ec2680: 0 CRUD-node array filters, by an AST scan with a lit control. The 70 array filters found are page, view and API filters.Verification (at
22f54b0738, after mergingorigin/mainc9761cd2fb)Tests
@objectstack/spec: full suite 673 files / 19431 passed / 1 todo, exit 0.typecheckexit 0 (check:test-typecheckdebt held).check:generated: all 15 up to date.@objectstack/service-automation: 173 files / 2112 passed,typecheck0. The ledger trio (node-config-contract-ledger,config-expression-ledger,node-config-required-keys) is green inside it.@objectstack/metadata-protocol: 218 passed / 3 skipped files, 27996 passed / 19 skipped tests;typecheck0.@objectstack/objectql: 378 files / 7507 passed;typecheck0.@objectstack/lint: 119 files / 5629 passed;typecheck0.@objectstack/cli,--project unit: 259 files / 3786 passed.--filter='!@objectstack/docs', VERDICT 0), so every suite reads a currentdist/.Gates
dispatch-gates --ranat22f54b0738: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.check-engine-split-ratio --days 90first refused on the shallow clone (exit 2). I deepened withgit fetch --shallow-since=2026-07-01; it then exits 0, with the ratio at 98.4% and the oldest visible commit at 2026-07-01, before the window.ESLint, narrowed to this PR's own changed files, at
22f54b0738:isPathIgnoredreports all 14 changed.tsfiles as linted.--format jsonreports 14 files, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the result for any file it does not touch.Declared to CI: the full
pnpm lint, the dogfood suite, the cli integration tier, and every package not named above.Acceptance notes (not filed)
script(and by reading,subflow) node with an undeclared config key passesFlowSchemaandvalidateStackExpressions, then registers, then fails every run.registerFlow's key check skips schemaless types, and this arm judges no key membership. It belongs to this card's family, key half; it was measured at the functions the doors call, at833d57c9cf. Carrier: none.get_recordlimit: '{n}'or a screen fieldmin: '{m}'is still refused at every run. The ⛔ above keeps it out of the build doors. Carrier: none.PARSED_AFTER_INTERPOLATION(http) andRUN_RESOLVED_KEYS(http.signingSecret) are new private tables in the judge.service-automation's ledger reconciles the contract map against the executors'parseNodeConfigcalls but reads neither table. A new after-interpolation executor or credential slot would have to be added here by hand. Carrier: none.defineFlowthrows while the CLI loads its config, the CLI prints the raw ZodError JSON, with the path relative to the flow and no flow name or file.Generated by Claude Code