Repository navigation
feat(spec)!: flow text slots read the {{ }} delimiter, refusing a single-brace token with its hole spelling (#22110) - #22315
Conversation
… holes (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…2110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…ow-text-slot-double-brace
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…id (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…ow-text-slot-double-brace
📓 Docs Drift Check45 anchor(s) derived from 4 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 140 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 8f93b51d0c2c8fe3f4af52c03e762ba086533eef && git checkout 8f93b51d0c2c8fe3f4af52c03e762ba086533eef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb ce4f6519fc9aad5d8665bb6da8615b8f75bed191 && git checkout -B drift-repro 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb && git merge --no-ff ce4f6519fc9aad5d8665bb6da8615b8f75bed191
node scripts/docs-audit/affected-docs.mjs --json 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb |
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22110 (body and all eight comments), PR #22315 (body, the 51-file list, the net diff ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Two reasons, both in the diff's own text, both one-line: ①.13 (the stale Generated by Claude Code |
…ow-text-slot-double-brace
…formatted-hole paths, changeset wording (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22110 (body and all eleven comments, the two rulings ① Derived judgmentsThe earlier record's two FAIL reasons, at this head:
The whole PR's accept-set and public-surface changes, each confirmed at this head:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Two reasons, one file, both one-sentence: ①.17 ( Generated by Claude Code |
|
CI on
Generated by Claude Code |
…ow-text-slot-double-brace
…ULE_ID` for the reasoning (objectstack-ai#22339) Part of objectstack-ai#22161 Clause-②: yes (widening: `os explain` accepts a rule id, and `packages/lint` exports the rule explanations) ## What changes - **`field-no-consumers` and `security-owd-unset` print one verdict sentence and one fix.** Their long reasoning moves into one static explanation per rule id, `RULE_EXPLANATIONS` / `explainRule()` in `@objectstack/lint` (new module `packages/lint/src/rule-explanations.ts`, exported from the root barrel and from a new import-free entry, `@objectstack/lint/rule-explanations`). Nothing else carries a copy. - **`os explain RULE_ID`** (the maintainer's spelling, one positional, no `rule` sub-word): a schema name resolves exactly as before; otherwise an exact rule id resolves to its explanation (`--json` prints `{ rule, covers, paragraphs }`). The no-argument listing also names the rule explanations (`--json` adds `rules: [{ id, covers }]`). An unknown id exits 1 and names both lists. - **The `rule:` line carries the pointer, spelled once** — `explainPointer()` / `authoringFindingDetailLines()` in `packages/cli/src/utils/format.ts`, used by the build advisory printer, the gating-error printer (validate, build, verify, init), the validate advisory list, and `os lint`'s rule line. It appears only for a rule id the table holds, so it never names a command that would answer "unknown". The hint line is labelled `fix:`. - **`os explain`'s schema lookup reads own keys only.** `os explain constructor` / `__proto__` printed `Schema: Object … undefined` and threw `schema.required is not iterable` on main. They are now refused as unknown ids (`6d2eb857c`; pinned in `test/explain-rule-id.test.ts`; with the own-key check reverted → 1 failed | 10 passed). - **The `fix:` line is always a fix** (patch round 2). `expression-invalid`'s authored source is a quote, not a fix, so it now ends the finding's `message` as `` — source: `…` `` and its `hint` is empty: the CLI prints no `fix:` line for it, and the source still reaches the text face and the runtime 422 issue. Four other hints that carried no instruction now open with one: `component-props-invalid` (its consequence moved into the message), `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (the contract-description branch), `liveness-experimental-property`. The maintainer's shape, as `os validate` and `os build` now print it on a tutorial-shaped project: ```text ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) fix: add it to a view column or a form section, or remove the declaration rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer ``` ```text • object "my_app_ticket": custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision fix: declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children) rule: security-owd-unset at objects[1].sharingModel — `os explain security-owd-unset` for why the baseline must be declared ``` ## Measured (local CLI built from this branch; tutorial-shaped project: `my_app_note` + `my_app_ticket`, a grid view on `title`/`status`) | | before (`59d993c97`) | after | |---|---|---| | `os validate`, `field-no-consumers` | one line, 852 chars; no fix, no rule id | 114 / 77 / 126 chars (verdict / fix / rule) | | `os build`, `field-no-consumers` | 852 + 692 + 62 chars | 114 / 77 / 126 | | `os validate`, `security-owd-unset` | 374 + 175 + 58 chars | 156 / 160 / 130 | | one `os dev --compile` run | printed once (the compile child), not again at serve | printed once, same shape | - **H1 holds:** the message and the build-time "Give … a consumer" text (the finding's `hint`) are built in `packages/lint/src/validate-field-consumers.ts`. `os validate` printed the registry advisory as its `⚠` line only (`commands/validate.ts`), with no fix and no rule line; `os build` printed message, hint and rule line through `printAuthoringAdvisories`. - **H2 holds, with one addition:** the `rule:` line is the CLI printer's, not the rules' (`utils/format.ts`, two printers). The pointer is spelled there once. `os validate`'s advisory list had no rule line at all, so it now renders the same two lines through the same helper (below). - **H3 holds:** 16 `os explain` schema names, 214 rule id constants exported from `packages/lint` — intersection empty (no rule id is a single word). Pinned in `packages/cli/test/explain-rule-id.test.ts` (lowercased, against every exported rule id constant). - **H4 does not hold:** one `os dev --compile -p PORT --fresh` run printed the warning once (`grep -c field-no-consumers` = 1, before and after). No printer change was made for it. - **H5:** far more than 8 over-long rules (below), so this PR builds the mechanism and shortens `field-no-consumers` and `security-owd-unset` only. The dead-button `action-governance` line is not an author-time rule: it is the boot-time `logger.warn` in `packages/objectql/src/action-governance.ts` (`[action-governance] declared script actions with NO handler …` — 163 chars as the source writes it, plus a `{count, actions}` payload; its sibling "registered handlers with NO declaration" line is 628). It lives outside `packages/lint` and outside the CLI printer, so it is named here and not edited. ## Landing outside the claim's file surface, and why - `packages/cli/src/utils/format.ts` — the H2 printer: `explainPointer()` and `authoringFindingDetailLines()`; both printers render through them. - `packages/cli/src/commands/validate.ts` — measured: `os validate` printed a registry warning with no fix and no rule line, so a shortened message would have reached the maintainer's first-named command with no pointer. The text face now prints the two lines under each registry advisory via the same helper. The `warnings` list `--strict` and `--json` read is unchanged. - `packages/cli/src/commands/lint.ts` — `os lint` prints the same shortened message; its rule line gains the same pointer (one call to `explainPointer`). - `packages/lint/package.json`, `packages/lint/tsup.config.ts`, `packages/lint/src/rule-id-barrel-exports.test.ts` — the new `./rule-explanations` entry. `format.ts` is documented as "a pure formatter with no rule-engine import", and every command imports it; loading the `@objectstack/lint` root barrel after `@objectstack/spec` measured 456–547 ms (three runs), which every command (`os explain object` included) would otherwise pay. The entry's module imports nothing (pinned by a source scan); its keys and the `field-no-consumers` roots list are literals held to the rule's constants by `rule-explanations.test.ts`. - `packages/cli/README.md` — the `os explain` row. - Tests updated for the new text: `packages/cli/src/utils/author-time-rules.test.ts` (read the field from `where`, not `message`), `packages/cli/test/truncation-remainder-notices.test.ts` (`fix:` label), `packages/cli/test/validate-build-gate-parity.test.ts` (classifies `authoringFindingDetailLines` as presentation). No test outside `packages/lint` / `packages/cli` pins either old message. ## Tests, round 1 (all local, this branch; head `315a26618` unless a run names another; round 2's readings are under `## Patch round 2`) New pins: `packages/lint/src/rule-explanations.test.ts` (every key is an exported rule id under its own key; `covers` fits the pointer; no tracker number in the text; the roots paragraph equals `CONSUMER_ROOTS` / `CARRIER_ROOTS`; exact-id lookup; the module imports nothing), the shape pins in `validate-field-consumers.test.ts` and `validate-security-posture.test.ts` (verdict line and fix line, exact), `packages/cli/test/explain-rule-id.test.ts` (H3 disjointness; every pointer target resolves through `Explain.run`; the printed verdict / `fix:` / `rule:` lines of each rule's REAL finding; schema lookup unchanged; unknown id exits 1), and `packages/cli/test/rule-line-explain-pointer.e2e.test.ts` (spawns `os validate` and `os build`; a `*.e2e` file, so the nightly tier). - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` → `Test Files 128 passed (128)`, `Tests 5853 passed (5853)` (at `ed786eb3e`; no lint file changed after it). - `pnpm --filter @objectstack/lint run typecheck` → exit 0, `check:test-typecheck: OK — … 2 file(s) / 6 error(s) / 2 pinned signature(s) held`. - `pnpm --filter @objectstack/cli run typecheck` → exit 0, `check:test-typecheck: OK — … 3 file(s) / 28 error(s) / 6 pinned signature(s) held` (at `315a26618`). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 --shard=N/3` (the full unit tier, in three foreground shards because one run exceeds the container's foreground cap under load): shard 1 `89 passed` / `1523 passed` and shard 2 `88 passed, 1 failed` (at `ed786eb3e`); shard 3 `89 passed` / `1264 passed` (at `315a26618`). The shard-2 failure was `src/utils/author-time-rules.test.ts` reading the field name from `message`; fixed in `315a26618` and re-run with `test/lint-per-package-authoring-seam.test.ts` → `2 passed` / `10 passed`. - `--project integration` (declared to CI as a whole), the ten files that spawn validate / build / verify / lint and read their text: `test/build-text-face-advisory-count`, `verify-author-time-stage`, `validate-per-package-authoring-parity`, `union-fold-command-parity`, `authoring-rule-command-parity`, `validate-view-container-name`, `build-view-container-name`, `picklist-reference-doors`, `lint-per-package-authoring-parity`, `validate-lint-mapping-connector-source` → `Test Files 10 passed (10)`, `Tests 62 passed (62)`. - `OS_TEST_TIERS=nightly … vitest run test/rule-line-explain-pointer.e2e.test.ts` → `2 passed`; `validate-json-warning-parity.e2e.test.ts` (the `⚠` line still pairs with `--json`) → `3 passed` (both on the `ed786eb3e` tree). - Ablation (one-shot, nothing kept): `scripts/ablation-replace.mjs` replaced `explainPointer`'s return with `''` in `packages/cli/src/utils/format.ts` (anchor 1 → 0, blob `9d90c98c409d` → `4427f41a866d`), `test/explain-rule-id.test.ts` → `3 failed | 7 passed`; restored, blob `9d90c98c409d` == HEAD, `git diff HEAD` empty. - Cross-package type read: `packages/cli` builds against `@objectstack/lint/rule-explanations`, an entry that exists only in the rebuilt `dist/` (`dist/rule-explanations.{js,cjs,d.ts,d.cts}`), so the CLI build read the rebuilt declarations. CJS `require` and ESM `import` of the entry both load (`['field-no-consumers', 'security-owd-unset']`). - ESLint, narrowed to the diff: `npx eslint --no-inline-config --format json` over the 18 changed `.ts` files → 18 files in the JSON report, 0 errors, 0 warnings; `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, its own comment at `:327`), so this diff cannot move a verdict on an untouched file. Repo-wide `pnpm lint` is CI's. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `315a26618` derived 78 commands, a superset of the 51 at dispatch. Ran all 78: 76 exit 0; `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n-coverage` exit 3, PREREQUISITE NOT MET (packages outside the CLI's build closure have no `dist/` in this worktree) — NOT MEASURED, CI's. Reconciliation: `✓ dispatch-gates --ran: 78 derived famil(ies) accounted for — 76 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).` Also run, exit 0: `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` (the three artifact-roster gates whose roster sits under `packages/`), `check:published-readme-exports`, `check:published-readme-links`, `check:cli-examples-parity`. Control-character scan over every changed file: no match. ## Second stage — the over-long rules this PR does not shorten Measured by running the whole `packages/lint` suite at `59d993c97` (127 files, 5843 tests) with a scratch hook that recorded, per rule id, the longest `message` of any finding pushed: 240 rule ids fired, 157 with a message over 200 characters. Lengths are the `message` alone (the printed line adds `where` and `: `). A rule id that fired in no test is not in this count. **Second stage, in `packages/lint` (not touched here) — 124 rule id(s):** - `validate-rls-predicate-enforceability.ts`: `rls-predicate-unparseable` 2056, `rls-predicate-unenforceable` 1679, `rls-predicate-unknown-user-variable` 1544, `rls-predicate-unknown-field` 1399, `rls-predicate-over-budget` 1259 - `validate-sharing-rule-enforceability.ts`: `sharing-rule-unlowerable-condition` 1093, `sharing-rule-object-not-shareable` 774, `sharing-rule-object-controlled-by-parent` 660, `sharing-rule-runtime-variable-condition` 492 - `validate-rule-schema-formats.ts`: `validation-rule-json-schema-unknown-format` 1049 - `validate-action-dispatch-contract.ts`: `action-dispatch-contract-mismatch` 927 - `validate-component-props.ts`: `component-props-invalid` 920, `component-props-unknown-key` 844 - `validate-sortable-fields.ts`: `sort-field-unprovisioned` 844, `sort-field-unsortable` 369, `sort-field-unknown` 287 - `validate-dataset-measure-aggregates.ts`: `measure-aggregate-field-type-refused` 809, `dimension-json-stored-field-refused` 531 - `validate-component-types.ts`: `component-type-unknown` 807 - `validate-flow-trigger-readiness.ts`: `flow-time-relative-descriptor-invalid` 796, `flow-time-relative-descriptor-unroutable` 532, `flow-trigger-unroutable` 518, `flow-api-trigger-secret-missing` 336, `flow-trigger-unknown-event` 222 - `validate-hook-body-writes.ts`: `hook-body-write-unprovisioned-anchor` 792, `hook-body-write-unknown-field` 465, `hook-body-source-unparseable` 212 - `validate-react-page-props.ts`: `react-chart-drilldown-invalid` 784, `react-chart-aggregate-invalid` 507, `react-chart-field-unprovisioned` 429, `react-block-needs-record-context` 267, `react-page-source-unparseable` 211 - `validate-action-body-writes.ts`: `action-body-write-unprovisioned-anchor` 778, `action-body-write-unknown-field` 433, `action-record-write-discarded` 293, `action-body-source-unparseable` 212 - `validate-flow-node-writes.ts`: `flow-node-write-unprovisioned-anchor` 739, `flow-node-write-unknown-field` 421 - `validate-security-posture.ts`: `security-controlled-by-parent-ambiguous-relation` 697, `security-fls-unknown-field` 593, `security-controlled-by-parent-no-relation` 526, `security-master-detail-ungranted` 449, `security-owd-alias` 354, `security-delegation-missing-reason` 210 - `validate-preset-comparands.ts`: `filter-preset-comparand` 669 - `validate-visibility-predicates.ts`: `visibility-predicate-unknown-function` 655, `visibility-predicate-over-budget` 507, `visibility-bare-identifier` 411, `visibility-predicate-syntax` 341, `visibility-root-mislayered` 304 - `validate-predicate-path-refs.ts`: `predicate-rhs-path-shaped` 648, `predicate-path-unrooted` 434, `predicate-path-unresolved` 371 - `validate-page-visualization-bindings.ts`: `page/visualization-without-binding` 629 - `validate-translatable-sections.ts`: `translation-section-name-missing` 553 - `validate-nav-object-servability.ts`: `nav-object-unservable` 528 - `validate-searchable-fields.ts`: `searchable-field-unprovisioned` 512, `searchable-field-unsearchable` 449, `searchable-field-unknown` 295 - `validate-widget-bindings.ts`: `dashboard-filter-field-unprovisioned` 509, `chart-field-unknown` 407, `dashboard-filter-field-not-included` 370, `widget-filter-field-unknown` 364, `dashboard-filter-field-unknown` 333, `chart-dimensions-missing` 306, `widget-filter-field-not-included` 282, `widget-measures-missing` 255, `widget-sortby-unselected` 242, `chart-measures-missing` 224, `widget-legacy-analytics-unrenderable` 205 - `validate-rule-compilability.ts`: `validation-rule-json-schema-uncompilable` 489, `validation-rule-regex-uncompilable` 482 - `validate-page-field-bindings.ts`: `page-field-unprovisioned` 484, `page-section-group-unknown` 214 - `data-model-rules.ts`: `unique/legacy-organization-composite` 478, `unique/unscoped-declared-index` 427, `unique/double-declaration` 381 - `validate-mapping-target-fields.ts`: `mapping-target-field-unknown` 466 - `validate-ai-agent-authoring.ts`: `default-agent-legacy-alias` 466, `default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352 - `validate-readonly-hook-writes.ts`: `hook-api-update-readonly-field` 464, `hook-api-update-readonly-when-field` 267 - `validate-approval-approvers.ts`: `approval-approvers-may-resolve-empty` 451, `approval-approver-not-membership-tier` 272 - `validate-dataset-references.ts`: `dataset-field-not-included` 446, `dataset-field-unknown` 296, `dataset-filter-field-unknown` 294, `dataset-include-unknown` 260 - `validate-list-view-field-refs.ts`: `list-view-field-dotted` 445, `list-view-field-unknown` 355 - `validate-chart-bindings.ts`: `chart-measure-unknown` 442, `chart-axis-not-selected` 327 - `validate-ai-tool-references.ts`: `ai-skill-tool-unresolved` 441 - `lint-view-refs.ts`: `view-ref-nav-view-missing` 437, `view-key-collision` 257 - `validate-nav-target-refs.ts`: `nav-target-unresolved` 426 - `validate-managed-api-methods.ts`: `object/managed-api-method-unaffordable` 412 - `validate-readonly-flow-writes.ts`: `flow-update-readonly-field` 410, `flow-update-readonly-when-field` 317 - `validate-action-name-refs.ts`: `action-name-undefined` 409 - `validate-readonly-action-writes.ts`: `action-api-update-readonly-when-field` 396 - `lint-flow-credential-literals.ts`: `flow-credential-literal` 390 - `validate-filter-tokens.ts`: `filter-token-unknown` 386 - `validate-print-page-blocks.ts`: `print-page-block-unprintable` 368 - `validate-org-axis-red-lines.ts`: `org-axis-cross-org-bu-grant` 365 - `validate-nav-access.ts`: `nav-object-ungranted` 353 - `validate-empty-combinators.ts`: `filter-empty-combinator` 352, `filter-empty-node` 226 - `validate-translation-references.ts`: `translation-target-unknown` 345, `translation-option-key-unknown` 230 - `validate-object-references.ts`: `object-reference-unregistered-platform` 324 - `validate-object-field-refs.ts`: `object-field-ref-unknown` 320 - `validate-seed-state-machine.ts`: `seed-value-outside-state-machine` 320 - `validate-semantic-roles.ts`: `semantic-role-field-unprovisioned` 291 - `validate-view-containers.ts`: `view-container-shape` 290 - `validate-ai-surface-affinity.ts`: `ai-skill-surface-mismatch` 281 - `validate-flow-filter-tokens.ts`: `flow-filter-token-unknown` 278 - `validate-dashboard-action-refs.ts`: `dashboard-action-route-unresolved` 242, `dashboard-action-target-undefined` 239 - `validate-retired-permission-residue.ts`: `permission-retired-lifecycle-residue` 235 - `validate-seed-replay-safety.ts`: `seed-insert-mode-duplicates-on-replay` 222 - `validate-capability-references.ts`: `capability-reference-unknown` 219 - `validate-form-layout.ts`: `form-section-group-unknown` 214 **Excluded this round — files open PRs objectstack-ai#22268, objectstack-ai#22315, objectstack-ai#22319 edit — 19 rule id(s):** - `validate-expressions.ts`: `expression-invalid` 2027 - `lint-flow-patterns.ts`: `flow-multi-write-unfiltered` 656, `flow-decision-mode-invalid` 528, `flow-loop-body-uncontained` 522, `flow-try-catch-without-catch` 520, `flow-approval-revise-target-not-service-owned` 366, `flow-decision-unconditional-branch` 342, `flow-error-label-not-fault` 315, `flow-inert-node-condition` 286, `flow-runas-unscoped` 284, `flow-branch-label-unmatched` 272, `flow-decision-inclusive-overlap` 250, `flow-default-edge-with-condition` 239, `flow-multiple-default-edges` 211, `flow-time-relative-antipattern` 208, `flow-date-equality-filter` 208 - `validate-flow-template-paths.ts`: `flow-template-field-unprovisioned` 440, `flow-template-lookup-traversal` 349, `flow-template-unknown-field` 258 **Message lives outside `packages/lint` — `packages/spec/src/kernel/functional-completeness.ts` (named, not edited) — 8 rule id(s):** - `functional-completeness.ts`: `view/row-color-without-colors` 793, `view/layout-without-binding` 673, `webhook/without-triggers` 594, `view/tree-without-parent-field` 592, `field/summary-without-operations` 319, `field/formula-without-expression` 259, `field/choice-without-options` 250, `field/relationship-without-reference` 239 **Not an author-time registry rule — 4 rule id(s):** - `lint-startup-registry-verdict.ts` (the repo gate `check:startup-registry-verdict`): `startup-open-vocabulary-verdict` 779, `startup-verdict-assertive-wording` 731 - `data-model-rules.ts` `lintDataModel` (`os lint`'s own data-model rubric): `relationship/master-detail-required` 462, `rollup/non-numeric-aggregand` 364 Each second-stage rule takes the same shape: move the long text into `RULE_EXPLANATIONS` (the pointer then appears on its `rule:` line by itself), leave one verdict sentence and one fix, and pin the new shape in the rule's own test. The excluded three files can follow once objectstack-ai#22268, objectstack-ai#22315 and objectstack-ai#22319 land. ## Acceptance notes - The `fix:` label now prefixes the hint under every author-time finding the CLI prints (build, validate, verify, init), not only the two shortened rules. Round 2 measured every hint producer for text that is not a fix and changed five (listed under `## Patch round 2`); every other rule's hint text is unchanged. Borderline rows were counted as fixes, because each carries an instruction or a spelling to write: `validate-component-types.ts:150`, `validate-flow-trigger-readiness.ts:632`, `runtime-gate.ts:1020`, `lint-liveness-properties.ts:254` / `:273`, and the `fix` snippets in `functional-completeness.ts`. - `expression-invalid`'s runtime 422 issue now carries `hint: ''`; its message carries the source. objectui's save-advisory toast already skips an empty hint (`saveAdvisoryToast.ts:97`). The one place that prints the bare value is the deduped operator log line in `metadata-protocol` `runtime-authoring-gate.ts:1130` (`… (${advisory.hint})`), which now ends in `()` for an `expression-invalid` warning. It is cosmetic, server-log only, and not changed here. - `os validate`'s text face now shows `fix:` and `rule:` lines under every registry warning (it showed neither before); the `warnings` list `--strict` and `--json` read is unchanged, so `validate-json-warning-parity.e2e.test.ts` still pairs the faces. - Runtime publish gate: `security-owd-unset` also runs at the metadata write door, so a Studio / REST / MCP refusal carries the shorter message and hint too; the explanation is reachable from the CLI only. - `origin/main` `e9a1f5c40` is merged (`d33862bde`, a merge commit). - No new gate and no length ratchet (the ruling); each shortened rule's own test pins its shape. ## Patch round 2 (seat order `6067462250` → `74bed8f56`) Written into this body by the `domain:spec` seat 2 at 2026-10-08T20:46Z from the dev's report `6068666691`; the role file reserves a later body edit to the seat. - **Measured, non-fix hints** (static read of the 274 `hint:` values in `packages/lint/src`, plus the `fix:` values in `functional-completeness.ts` and the shared hint helpers). Five, at the stop condition's limit, none in the three excluded files: - `authoring-rules.ts:687`, `expression-invalid`: quoted the source. The source now ends the message, and the hint is empty. - `validate-component-props.ts:336`, `component-props-invalid`: context only. The hint is the fix, and the consequence moved into the message (a CLI-only rule). - `validate-flow-trigger-readiness.ts:497`, `flow-time-relative-descriptor-invalid`: context only. The hint opens with the instruction. - `validate-react-page-props.ts:1166`, `react-prop-missing-required`: the hint was the binding's description alone. It is now `Pass REQ={…}: DESCRIPTION`. - `lint-liveness-properties.ts:247`, `liveness-experimental-property`: the hint was a statement. It now opens with an instruction. - **Pin:** `packages/cli/test/explain-rule-id.test.ts` runs the real registry adapter on the tutorial's action and prints through `printAuthoringRuleErrors`. It asserts exactly two lines, the source inside the verdict line and no `fix:` line. Ablated with the dist leg (adapter reverted, lint rebuilt): 1 failed | 11 passed. Restored to the HEAD blob, and the rebuilt dist carries no marker. - **Docs blocks re-rendered from the printer:** `content/docs/getting-started/build-with-claude-code.mdx` `:309`–`:313` and `content/docs/ui/react-pages.mdx` `:361`–`:363`, `:403`–`:405`. The `:403` block was already stale before this PR (the fallback hint where the contract has a description). Cross-lane on objectstack-ai#6023. - **Changeset:** it names the runtime-wire `message` change for `expression-invalid`. Its count of the reworded rules that reach a runtime response is corrected in patch round 3, below. - **Readings:** - lint: 128 files / 5853 passed, and typecheck exit 0, both at `e84732425`. - cli: unit 4 files / 120 passed and integration 4 files / 21 passed (the spawn tests that print or read `expression-invalid`), and typecheck exit 0, all at `819444f50`. - ESLint on the 7 changed `.ts` files: 0 errors / 0 warnings. - `dispatch-gates --commands` (no paths) at `819444f50`: 106 derived (round 1's 78 plus 28 docs/spec families), all 106 exit 0. - The three dist-reading gates exited 3 on the fresh worktree and exit 0 after the remaining packages were built. - `--ran`: 106 run, 0 NOT-MEASURED. The 20 changeset/text families re-ran on `74bed8f56`: exit 0. - Round 1's two NOT-MEASURED gates (`check:dual-build-cjs-loads`, `check:i18n-coverage`) also exit 0 at `6d2eb857c`. - **Line budget:** round 2 is 10 files, +87 / -18. The whole PR against `e9a1f5c40` is 28 files, +919 / -81. Governed paths touched: 0. ## Patch round 3 (contract review FAIL `6068965879` → seat order `6068983639` → `1e016895c`) Written into this body by the `domain:spec` seat 2 at 2026-10-08T21:10Z from the dev's direct report; the role file reserves a later body edit to the seat. One commit, `.changeset/22161-rule-message-one-line.md` only (+4 / -2). Each sentence was checked against `surfaces`, `runtimeTypes` and severity in the code before it was written. - **The reworded hints at the gate.** Only `flow-time-relative-descriptor-invalid` reaches a 422 `hint`: it is an `error` on `flow` writes. - `liveness-experimental-property` is always a `warning` on `email_template`, `mapping` and `datasource` writes, so it would ride the 2xx `advisories`. No ledger row on those types is `experimental` today, so it reaches no runtime response yet. This corrects the seat's own order, which put it on the 422. - `component-props-invalid` is CLI-only. - `react-prop-missing-required` judges no `page` write at the gate. - **`security-owd-unset` at the `object` write door.** A custom object (neither `isSystem` nor `sys_`-named) with no `sharingModel` is refused with a 422, and its issue now carries the new `message` and `hint`, both quoted verbatim. `where` and `path` still carry the object; `os explain security-owd-unset` prints the incident. - **`expression-invalid`**: the source rides the issue `message` at the gate for `flow`, `action`, `hook` and `object` writes. An `error` lands in the 422, a `warning` in the 2xx `advisories`. The runtime `hint` is `''`. - **`os explain` unknown id:** it still exits 1. The text changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a `Rules with an explanation: …` line. The `--json` `error` changes the same way. - **Gates at `1e016895c`:** the 20 families `dispatch-gates --commands` derives for the changeset, plus `check-changeset-fixed.mjs`, all exit 0. No `PREREQUISITE NOT MET`. `main` was not merged (the push was accepted). --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…al-less readings outside packages/spec say D5 refuses it (objectstack-ai#22357) Fixes objectstack-ai#22345 Clause-②: no (narrowing) `domain:services` seat 1, branch `claude/issue-22345-pre-d5-reading-pass`, dispatched under the claim `6068302217`, executing triage `6068146867`. The pre-D5 family's `packages/spec` half is objectstack-ai#22302 / PR objectstack-ai#22327 and is not touched here. ## What this does - **Retires `RunProvenanceContext`** from `@objectstack/service-automation`: the interface, its arm of `RunDataContext`, the type export in `src/index.ts` and the README export list. `RunDataContext` is now `interface RunDataContext extends RunIdentityContext {}`, which is exactly the set of shapes `resolveRunDataContext` returns. Only type declarations change. - **Closes the pre-D5 reading outside `packages/spec`.** 24 comment or docstring sites in 11 packages said, in the present tense, that a principal-less or `{ flowRunId }`-only context falls open, is handed through, or is skipped by the data security middleware. Each now says that the middleware used to do this, and that ADR-0096 D5 refuses such a context. These edits are comment text only. - **No runtime behaviour changes.** Comments were stripped with `scripts/js-comment-mask.mjs` `stripComments` and whitespace was collapsed. After that, 18 of the 20 changed `.ts` files are byte-identical to base `35afb15878`. The other two are `runtime-identity.ts` and `index.ts`. They differ only by the removed `interface RunProvenanceContext`, the `RunDataContext` declaration, and the dropped name in the type export. The class body between those two declarations is byte-identical. ## H1: producers and readers (base `35afb15878`) - `git grep -n RunProvenanceContext` finds 4 hits: `runtime-identity.ts:75` (the declaration), `:124` (the union), `index.ts:195` (the type export) and `README.md:451` (the export list). There is no other reference in `packages/**`, `apps/**`, `examples/**` or `packages/qa/**`. The pinned sibling `objectui` at `a58626c88d` has none either: `git grep -E 'RunProvenanceContext|RunDataContext'` exits 1, and in the same shallow fetch `service-automation` matches 14 files as the control. - `RunDataContext` by name: `runtime-identity.ts:124` (the declaration), `:178` (the return type of `resolveRunDataContext`), `:275` (a parameter of `stampSystemInsertOwner`), `index.ts:195` and `README.md:450`. It has one reader outside the package: `plugin-approvals/test-typecheck-debt.json:43`. That is a ledgered TS2352 on `record-lock-schedule-run.integration.test.ts:150`, which casts `resolveRunDataContext(...)` to a record because the union's provenance arm had no `isSystem`. - **No production code builds an engine context that carries `flowRunId` and no principal.** Every data node resolves its context through `resolveRunDataContext` (`crud-nodes.ts:484/572/720/812`, `plugin.ts:1248`). `engine.ts:6079` stamps `flowRunId` on the run's `AutomationContext`, which is not an engine context. Only tests build such a context: - `objectql/src/engine.test.ts:601` (a bare engine; it pins hook provenance); - `plugin-security/src/delegated-admin-gate.test.ts:137` and `system-write-guard.test.ts:94` (gate units); - `principal-less-strict-mode.test.ts:135` (refused by D5). The `provenance: { flowRunId }` fixtures with no session in the plugin-approvals, plugin-audit and service-storage tests are `HookContext` shapes. None of these names the type. - **The docstring said the type "survives for the non-data provenance uses that motivated objectstack-ai#3712". None was found.** The objectstack-ai#3712 use is the approvals record lock. It reads `HookContext.provenance.flowRunId` (`plugin-approvals/src/lifecycle-hooks.ts:489`), which objectql's `buildProvenance` builds from any `ExecutionContext`, and `RunIdentityContext` already carries `flowRunId`. - Result: the retirement condition holds, and the type is retired. ## H2: what D5 does today (on `main`) - `plugin-security/src/security-plugin.ts:2451`: `if (opCtx.context?.isSystem) return next()`. This system short-circuit runs first. - `:2654`-`:2656`: `if (isPrincipalLessContext(opCtx.context)) throw principalLessDenial(...)` throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb. It runs after the package-managed, system-row, curated-capability, audience-anchor, engine-owned-write and delegated-admin gates. The predicate is at `:383`-`:387`. The probes agree with it: `getReadFilter` returns the deny filter (`:5947`), and `canReadObject` (`:6384`) and `canExport` (`:6778`) answer `false`. Landed in `a3bcbcf3ca` (PR objectstack-ai#22297), and `git merge-base --is-ancestor a3bcbcf origin/main` exits 0. - `resolveRunDataContext` (`service-automation/src/runtime-identity.ts`) returns, by `runAs`: - `runAs: 'system'`: `{ isSystem: true, actor: 'svc:flow:NAME', userId?, tenantId?, positions: [], permissions: [], flowRunId? }`; - `runAs: 'user'` with a user: `{ isSystem: false, userId, positions, permissions, tenantId?, flowRunId? }`; - no user: it throws `UnscopedRunDataAccessError` (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`). ## H3: the enumeration (the pin) **Why there is no test file.** The repo's closest precedents pin a relation or a structure, never wording. `rest-server-docblock-position.test.ts` says so: "Wording is not pinned here on purpose: nothing parses these sentences". Nothing parses these comments either. So the pin is this command and its output, re-runnable on any tree: ``` git grep -n -i -E "middleware('s)? (skips (when|every|its)|skipped (a|when|every|its)|would skip|waves|waved|takes its principal-less|SKIPS)|(skips|skipped) when there is no (principal|identity)|wave[sd]? (it |them )?straight through|principal-less (fall-open|hand-off|.return next)|empty-principal (fall-open|skip)|(falls|fell|fall|falling) open for principal-less|plugin-security('s)? (principal-less )?(falls|fell) open|indistinguishable from passing no context|security[- ]skipped|ADR-0096 E1|straight to .next\(\)|(be|been) handed straight through|middleware handed it" -- . ':!packages/spec/**' ':!**/CHANGELOG.md' ``` It gives 82 lines at base `35afb15878` and 76 at head `f3a9675f4b`. The grep is line-based, so the sweep also paired subject and claim terms across 8-line windows to catch sentences split over lines. That window sweep found the fixed sites below that the grep alone misses. ### Fixed in this PR: current tense and false now (base positions) | # | Site | The false sentence | |:-|:-|:-| | 1 | `service-automation/src/runtime-identity.ts:56-74` | `RunProvenanceContext`: "the empty-principal fall-open … indistinguishable from passing no context at all" (retired with the type) | | 2 | `service-automation/src/runtime-identity.ts:85-86` | `UnscopedRunDataAccessError`: "the data security middleware skips when there is no principal" | | 3 | `service-automation/src/runtime-identity.ts:220-223` | "presenting none means the data security middleware skips every principal gate" | | 4 | `service-automation/src/runtime-identity.ts:333-336` | `runIsUnscopedUserMode`: "a principal-less context that the security middleware would wave straight through" | | 5 | `service-automation/src/engine.ts:6057-6058` | `resolveRunContext`: "the data security middleware skips when there is no identity" | | 6 | `service-automation/src/builtin/crud-runas.test.ts:219-220` | "which the data security middleware waves straight through" | | 7 | `service-automation/src/builtin/crud-runas.test.ts:258-259` | "the data security middleware skips" | | 8 | `service-analytics/src/strategies/objectql-strategy.ts:410-411` | "reaches the engine principal-less and plugin-security falls open" | | 9 | `plugin-security/src/security-plugin.ts:6061-6065` | `getMetadataReadableFields`: "mirrors the engine middleware, which skips its grant-based gates for a caller with no permission sets" | | 10 | `plugin-security/src/get-metadata-readable-fields.test.ts:8-10, :84` | "the engine middleware skips its whole gate for such a caller"; "middleware-mirroring fall-open" | | 11 | `platform-objects/src/system/sys-secret.object.ts:50-51` | "read with no principal (middleware falls open for principal-less internal calls)" | | 12 | `metadata-protocol/src/protocol.ts:11233-11237` | "this read passes no context"; "the middleware takes its principal-less `return next()`" | | 13 | `metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts:7-9` | "plugin-security hands … straight to `next()`" | | 14 | `plugin-auth/src/auth-plugin.ts:2480-2482` | "— the principal-less hand-off (ADR-0096)" | | 15 | `plugin-auth/src/auth-manager.ts:3384-3386` | "(the security middleware's principal-less hand-off, ADR-0096)" | | 16 | `plugin-auth/src/scim-connection-service.ts:121-122` | same | | 17 | `plugin-auth/src/principal-less-producers-system-context.test.ts:13-15` | "A context with neither … is the security middleware's principal-less hand-off" | | 18 | `runtime/src/http-dispatcher.ts:1244-1246` | "these calls carry NO ExecutionContext, so the data engine's security middleware skips RLS / FLS / CRUD / tenant scoping entirely" (the facade has carried `{ ...caller, isSystem: true }` since objectstack-ai#3914) | | 19 | `runtime/src/http-dispatcher.ts:1510-1511` | "(the security middleware's principal-less hand-off, ADR-0096)" | | 20 | `runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:551-556` | "which the security middleware hands straight through"; "once a principal-less context is denied too" | | 21 | `trigger-record-change/src/record-change-integration.test.ts:395-396` | "the data security middleware skips when there is no principal" | | 22 | `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:10-12` | "the security middleware SKIPS (it delegates auth to the auth layer)" | | 23 | `qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts:15-17` | "once the engine-level deny for the principal-less hand-off lands" | | 24 | `examples/app-showcase/src/automation/flows/index.ts:1584-1586` | "Without this it relies on the 'no identity → security-skipped' fall-through" | ### The pin's 76 lines at head `f3a9675f4b`, each with its class - **Fixed here (15):** the lines of rows 1-24 that still match, now in their corrected form: `examples/…/flows/index.ts:1586`, `protocol.platform-store-system-opt-in.test.ts:8, :9`, `protocol.ts:11237`, `auth-plugin.ts:2481`, `principal-less-producers-system-context.test.ts:14`, `scim-connection-service.ts:122`, `get-metadata-readable-fields.test.ts:9`, `security-plugin.ts:6062`, `declarative-endpoint-anonymous-guest.dogfood.test.ts:17`, `dispatcher-plugin.endpoint-fallback.integration.test.ts:552`, `http-dispatcher.ts:1514`, `crud-runas.test.ts:220`, `engine.ts:6058`, `runtime-identity.ts:206`. - **Historical, left (36):** - Release text, not edited in a code PR: `.changeset/21908-by-id-producers-opt-in.md:6`, `.changeset/21908-principal-less-producers-final.md:8`, `.changeset/21908-principal-less-strict-mode.md:20` and `content/docs/releases/v17/17-0.mdx:302`. - Decision records (Tier H): `docs/adr/0096-…:11, :38, :156` and `docs/adr/0138-…:111, :635`. - "Before …" / "used to …" / "which D5 closes": `auth-manager.org-slug-guard-system-context.test.ts:10`, `http-dispatcher.membership-system-context.test.ts:10`, `principal-less-strict-mode.test.ts:9`, `security-plugin.ts:359, :2650, :6212`, `zero-set-capability-fold.test.ts:40`, `zero-set-masking.test.ts:37`, `webhook-system-context.pin.test.ts:11`, `datasource-system-context.pin.test.ts:16`, `inbox-system-context.ts:16`, `sql-http-outbox.ts:473`, `system-context.pin.test.ts:18` (service-messaging), `settings-system-context.pin.test.ts:11`, `metadata-store.ts:143, :174, :196, :197, :198` and `owner-of-private-object-under-strict-mode.dogfood.test.ts:8`. - objectstack-ai#3597 and objectstack-ai#1888 history: `analytics-rls.dogfood.test.ts:9, :163`, `flow-runas-fixture.ts:27`, `flow-runas.dogfood.test.ts:30`, `execution-context-bridge.test.ts:16`, `service-analytics/src/plugin.ts:427` and `objectql-strategy.ts:852`. - **True, left (15):** - Names the hand-off as what ADR-0096 D5 closes: `auto-enqueuer.ts:30`, `redeliver-guard.ts:70`, `datasource-admin-plugin.ts:104`, `datasource-secret-binder.ts:40`, `fan-out-system-context.ts:25`, `outbox-dispatcher-scope.ts:94, :118, :135`, `settings-service-plugin.ts:414, :538` and `settings-service.ts:104`. - Negation: `public-form-grant-masking.test.ts:33, :233` ("never the principal-less hand-off"). - Describes the replacement: `tenant-audit-update-delete-half-repairs.test.ts:798`. - This PR's changeset quoting the removed sentence: `.changeset/22345-run-provenance-context-retired.md:17`. - **A string literal, not a comment, left (2):** both are test assertion messages that name the failure shape: `dispatcher-plugin.endpoint-fallback.integration.test.ts:571` and `runas-grant-resolution.integration.test.ts:102`. - **The sibling ADR-0056 D2 family, left (3):** `export-permission-axis.test.ts:143` (a test title), `rest/src/rest-server.ts:2640` and `runtime/src/domains/automation.ts:295`. These describe an AUTHENTICATED caller with zero permission sets, not a principal-less one. See the Acceptance notes. - **Another subject (5):** `docs/adr/0111-…:126` (the sharing middleware skipping `sys_record_share`), `better-auth-schema-parity.test.ts:13`, `can-write-object-admission.test.ts:576` and `security-plugin.ts:6521` (step 2.5 with no payload), and `text-match-sql.ts:237`. ## H4: `objectql/src/engine.ts` (`domain:engine`), not changed The sentence is now at `:5466`-`:5469`, after PR objectstack-ai#22337 landed: "A context carrying only write PROVENANCE (`{ flowRunId }`, all an identity-less flow run has — objectstack-ai#3712) is such a case: it says what produced the write, not who is calling, and surfaces through {@link buildProvenance} instead." **Reading:** the sentence does not assert the pre-D5 behaviour. It says nothing about the security middleware admitting or skipping that context. It describes `buildSession` returning no session for it, and that is still what the code does. So it is not changed, and this diff contains no objectql file. Its parenthetical producer claim, "all an identity-less flow run has", is a different staleness. It has been false since objectstack-ai#3760: a user-less `runAs: 'user'` run never reaches the engine, and a `runAs: 'system'` one carries `isSystem` and `actor`. It is listed in the Acceptance notes with its family. ## H5: retirement, dependents and reverse verification - **Why an interface rather than a type alias.** This was probed with tsc 6.0.3. The alias `type RunDataContext = RunIdentityContext` prints as `RunIdentityContext | undefined` in diagnostics. That re-spells plugin-approvals' ledgered TS2352 signature (`'RunDataContext | undefined'`) and turns its `check:test-typecheck` red (one ARRIVED, one VANISHED). The interface keeps the name, so no consumer ledger moves. - **Dependents typecheck.** `turbo run typecheck --filter="...^@objectstack/service-automation"` covers all 18 dependents. The six other published packages this diff touches were added with explicit `--filter`s. That is 24 packages, and all 24 declare a `typecheck` script. Result: `Tasks: 89 successful, 89 total`, 36 cached, at `c19dde3b5c`, before the merge of `main`. - **Reverse verification.** A probe file went into `plugin-approvals/src`. That package resolves `@objectstack/service-automation` through `exports` to `dist/index.d.ts`, rebuilt from this branch. tsc reported: - `TS2305: Module '"@objectstack/service-automation"' has no exported member 'RunProvenanceContext'`; - `TS2739: Type '{ flowRunId: string; }' is missing the following properties from type 'RunDataContext': isSystem, positions, permissions`. The probe was removed by a trap, and `git status --porcelain` printed nothing afterwards. ## Changeset `.changeset/22345-run-provenance-context-retired.md` grades `@objectstack/service-automation` as `minor`: BREAKING, an accept-set narrowing of one type and one removed type export, with the `!` banner. It also grades four packages as `patch` for comment text only. Their edited comment text ships, as measured in the built `dist`: | Package | Edited text found in | |:-|:-| | `plugin-security` | `dist/index.js` and `dist/index.d.ts` | | `runtime` | `dist/index.js` and `dist/index.d.ts` | | `service-analytics` | `dist/index.js` | | `platform-objects` | `dist/index.js` | The edited comments of `plugin-auth` and `metadata-protocol` do not ship. As a control, the code tokens next to them are present in the bundle (`withSystemContext(rawEngine)` 2, `CREDENTIAL_PROBE_CONTEXT` 3, `sys_metadata_audit` 8). ADR-0087 disposition: `not-required (runtime-interface-only packages/services/service-automation/src/runtime-identity.ts#RunDataContext)`. The gate verified it: "verified: …#RunDataContext (interface)". ## Cross-lane paths (comment-only, declared) - `plugin-security`, `plugin-auth`, `runtime`, `metadata-protocol`, `platform-objects`, `service-analytics` and `trigger-record-change`: rows 8-21 of the table. - `packages/qa/dogfood` and `examples/app-showcase`, both private: rows 22-24. - `objectql`: read under H4 and not changed. ## Verification (head `f3a9675f4b` = this branch merged with `main` at `54c3ce10ce`; PR objectstack-ai#22337 landed meanwhile; no conflict) - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2`: `Test Files 177 passed (177)`, `Tests 2171 passed (2171)`. Before the merge, at `c19dde3b5c`, it was 176 / 2163. - `pnpm --filter @objectstack/service-automation typecheck`: `check:test-typecheck: OK … 0 file(s) / 0 error(s)`. - The edited test files, one run each, all passed: | Test file | Tests passed | |:-|-:| | plugin-security `get-metadata-readable-fields.test.ts` | 7 | | metadata-protocol `protocol.platform-store-system-opt-in.test.ts` | 7 | | plugin-auth `principal-less-producers-system-context.test.ts` | 4 | | runtime `dispatcher-plugin.endpoint-fallback.integration.test.ts` | 21 | | trigger-record-change `record-change-integration.test.ts` | 9 | - After the merge, `turbo run build --filter=!@objectstack/docs` gave `72 successful, 72 total`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 79 commands from 22 paths at `f3a9675f4b`, and all 79 exited 0. `--ran` with recorded exit codes: "79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED (a DERIVED zero …)". Some of the gates' own verdict lines: - `check-adr-0087-registration`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"; - `check-system-context-census`: "OK — 118 elevation read sites in 20 packages"; - `check:nul-bytes`: "OK (scanned 10334 text file(s) … no raw ASCII control bytes)"; - `check:dual-build-cjs-loads`: "106 published require entry point(s) across 66 package(s) load"; - `check:published-files`, `check:dts-closure`, `check:test-source-alias`, `check:cross-package-test-inputs` and `check-issue-citations`: green. - ESLint on the 20 changed `.ts` files: `errors=0 warnings=0`, with 20 files counted from `--format json`. That run was narrowed to the changed files, which holds only because no file's lint result depends on another file: the population is the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block, and `eslint.config.mjs:327-328` states that the config "never enables type-aware linting (no `parserOptions.project` …)". The full lint is CI's. - Declared narrowings: - The dependents typecheck ran before the merge. The commits the merge brought in move no `service-automation` export, and CI's TypeScript Type Check runs them all. - The full test suites of the comment-only packages, and the Dogfood Regression Gate, are left to CI. Each of their changed files is comment-identical to base, as measured above. ## Acceptance notes - **Sibling family, outside this card's definition (ADR-0056 D2, the deny baseline).** These sites say that the middleware skips its CRUD gate for an AUTHENTICATED caller with zero permission sets. The step-2 CRUD gate has not been guarded on a resolved set since that change. The sites are `rest/src/rest-server.ts:2639-2642`, `runtime/src/domains/automation.ts:295-303` ("this surface refuses where `/data` falls open"), `plugin-security/src/export-permission-axis.test.ts:143-146` (a title and a comment) and `plugin-security/src/baseline-composition.test.ts:157-159`. Not a principal-less reading, so left as they are. Taker: none. - **The objectstack-ai#3712 producer premise, false since objectstack-ai#3760, with no admission claim.** These sites say that a schedule-triggered run reaches the data layer as `{ flowRunId }` with no session: `objectql/src/engine.ts:5467` and `:5524`, `plugin-security/src/delegated-admin-gate.test.ts:133-135`, `system-write-guard.test.ts:89-91`, `plugin-audit/src/comment-access-hooks.test.ts:186`, `service-storage/src/attachment-access-hooks.test.ts:147-151`, `plugin-approvals/src/approval-service.test.ts:2239-2242` and `lifecycle-hooks.ts:484-488`. The units they introduce still test real hook and gate behaviour for that shape. Taker: none. - **Runtime strings, outside "comments only".** The `UnscopedRunDataAccessError` message (`runtime-identity.ts:109-113`) and the run-setup warning (`engine.ts:6133-6135`) say a principal-less run "would execute UNSCOPED (elevated, RLS-bypassing)". On a kernel with plugin-security, that run is now a 403. Both are pinned: `crud-runas.test.ts:238` and `schedule-runas-e2e.test.ts:122` assert `/UNSCOPED/`. Not changed, because no runtime behaviour moves in this PR. The prescription they give (declare `runAs: 'system'`) is still right. - **Test titles are strings, so they are left.** `plugin-security/src/security-plugin.test.ts:2317, :2571, :2674` read "(gate is before the fall-open)", and `can-write-object-admission.test.ts:640` reads "before the fall-open". The gates still run before the D5 refusal, so the DENIES assertions hold. `trigger-schedule/src/schedule-runas-e2e.test.ts:95-96` reads "runs the flow UNSCOPED". - **Left on purpose:** - `service-automation/src/engine.ts:6292`, "Surfaces the user-less fail-open (see helper)", names the objectstack-ai#1888 case. It is not a claim about the middleware. - `objectql-strategy.ts:852` and `analytics-rls.dogfood.test.ts:8` say "the bridge passes no ExecutionContext". That is a tense slip inside a past-tense paragraph; the bridge has passed the caller's context since objectstack-ai#3602. - **An orphaned docblock.** `runtime/src/http-dispatcher.ts:1241-1254` is bound to no declaration, the shape `rest-server-docblock-position.test.ts` records for this file. Its text is corrected here; its position is not moved. - **A plugin-security design question, not answered here.** `getReadableFields` still answers the full field set (minus posture fields) for a caller with no permission sets, a caller the middleware now refuses. The docstring now says that, instead of calling it "mirroring". No consumer was measured reaching it with such a context. - **Not changed:** - `resolveRunDataContext` keeps its `| undefined` return type although no path returns `undefined`. - `skills/objectstack-query/SKILL.md:66` ("`{ flowRunId }` for provenance alone") is Tier H and stays with the card the PR objectstack-ai#22327 review says is owed. - **A possible overlap.** PR objectstack-ai#22315 (`domain:spec` seat 2) edits `service-automation/src/engine.ts` and `README.md` in other regions. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414) Part of objectstack-ai#20749 Clause-②: no Stage 30 of this card: the class (e) remainder, the test strings shipped under the `packages/spec/src` subdirectories, as ruled in `5902360492` on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12 files. This stage rewrites 7 of them (6 titles and 1 expect message, 8 ids) in 5 files: each now states what its record decided, or drops the number where the title already says it. The other 10 messages / 10 ids stay, 4 because earlier stages decided they are not citations and 6 because an assertion matches the string against text this claim does not let the stage edit; both groups are named under "What stays". Text only: no assertion, identifier, test count, code comment, file name or non-test file changes. No file is deferred. ## Census (re-taken first) The instrument is stage 28's `census28.cjs`, byte-identical (md5 `31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29 published): an AST walk over the `packages/spec/src` test files, one message per folded string (a lone literal, a template, or a plus chain) that matches the gate's id pattern, a title when the folded root is argument 0 of a describe / it / test / suite / bench call, comments never read. It was run against the three published readings before it was trusted, and all three reproduce exactly: 128 messages / 130 ids in 37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76 in 24 files at `b7e01fbbd`. | reading | messages / ids | files | |:--|--:|--:| | base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 | | stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base | 12 | | this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 | | the 5 edited files, this head | 0 / 0 | 0 of 5 | Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 / 3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1 / 1 more, all in `ui`: the title `carries no ruling date and no tracker id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322 (`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So `ui` reads 10 / 10 in 7 files, and nothing else moved. The census at `origin/main` `e75dceddd` (8 commits past the base, none touching the 12 files) reads the same 17 / 18 in the same 12 files, so nothing regrew while this stage ran. The reading is within one message of the claim's, so there was no re-cut. Per file, messages at base: `ai/build-progress` 2, `api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3 ids), `contracts/approval-service` 1, `kernel/manifest` 1, `ui/action-description` 1, `ui/component-props-unknown-members.pin` 1, `ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2, `ui/notification` 1, `ui/strictness-batch14` 1, `ui/view-submit-redirect-url` 2. Controls: - Pathspec: the 12 named paths hit the control word `describe(` in 12 of 12 files and a nonsense word in none; the census scanned 12 of 12. - Planted, in a scratch tree: an id in a describe title, a plus-chain title, an expect message, a template literal, a cross-repo spelling and a ledger-style string each read once (6 / 6); a comment, a six-digit colour, an HTML entity, a two-digit number and a hex colour with a letter read 0. - Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and 2 messages at base and 0 at the head; the 7 untouched files read the same at both ends. ## Deferral At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan before opening this PR (04:13Z), 13. Every file list was read through REST (605 and 593 rows). None touches any of the 12 files: lit control `api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are open; none of them touches a file in this group. Deferred files: none. ## What changed 7 literals, one line each, in 5 files: +7 / -7. Every file keeps its line count. - `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix goes; the title already says what objectstack-ai#22126 landed, that the read serves the version token and the 409 carries the current one as data. - `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the `{token}` dialect in flow value slots; the title already stated both, so only the two numbers go. - `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix goes from the REFUSES title. - `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`: the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help and refusals carry no service-interface name, ruling date or foreign example id, and both titles already say what their bodies pin. - `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)` goes from the title. - `ui/view-submit-redirect-url.test.ts:118`: the expect message `states the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an assertion's failure message, so it was needle-checked first (below) and is the one declared non-title string. All seven are "drop a number the title already explains". None needed a rewrite in new words, because each title already carried the decision. ## What stays, and why 10 messages / 10 ids in 7 files, none edited. Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at `ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111', 'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them by file and line, and every later stage carried them forward. Six are strings that an assertion matches against text outside this stage's edit surface. Moving one at the same strength means editing a non-test source docblock (and, for the first two, its generated reference page) or the assertion that matches it. The claim forbids both and says to stop and report, so none is touched; `open_questions` in the report carries the decision. - `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237` `'objectui#7388 block 2'`: `toContain` over the source text of `ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which `content/docs/references/ai/build-progress.mdx` renders. - `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over the docblock above `continueRestoredRun` in `contracts/approval-service.ts` (line 999). - `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the tombstone note in `ui/notification.zod.ts:94`; the file's own `toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note. - `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over `ui/notification.zod.ts` and `ui/sharing.zod.ts`. - `ui/component-props-unknown-members.pin.test.ts:322` `ruling: 'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage 20's ACCEPT (`5998488373`) kept it for this reason and sent it to the needles' stage. Readers of the seven rewritten strings: none. `git grep -F` at HEAD over the tracked tree outside the 12 files, with the full literal, a 24-character window around each id, and the text on each side of each id (29 needles over all 17 sites): the only hits are the readers of the kept strings named above, the lit control (`composeStacks` in `stack.zod.ts`) hits and the dark control does not. The same needles searched inside the 12 files, outside each literal's own span: the only hits are two code comments beside `:322`. The five short needles (`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the script's 12-character floor, so their readers were confirmed by direct `git grep -F` with a dark control. ## Cited records Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8 comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`, landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the `{token}` dialect in flow value slots and keeps two spellings (the date macros and `{$User.*}`) until CEL can write them. The describe's PRESERVATION test still accepts those two, and the title keeps the record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the test body say the same thing the record says. ## Verification At head `8885dbf1c` (one commit on base `11d119ab1`): - **Text only.** `textonly28.cjs` (stage 28's, md5 `957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at 2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared string (`--declared 118`). Every other string token, identifier, number, punctuation mark and comment is byte-equal. 16 controls, expectations written in the script before the first run, 16 / 16 as predicted: an identifier rename, a numeric literal, a comment edit, an undeclared expect message, a rewritten title given a new id, an id-free title edited, one title reverted to base (SAME, 0 changed), a declared label without `--declared`, a declared line plus another changed string, the declared line alone (SAME, 2 changed), a title re-split into a plus chain, a test added, an untouched file (SAME, 0 changed), an id appended to a rewritten title, a kept needle rewritten, a kept hex colour rewritten. The two controls that mutate a string beside the declared line fail at the mutated line, not at 118. - **Tests, 12 files, base and head.** `--project local --project repo` with the JSON reporter, 618 tests in 88 suites each side, all passed. Per-file test count and status sequence identical in 12 / 12. 23 full names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3 and 3, the same three `[object Object]` it.each rows at both ends. - **Full spec unit tier at the head**, under the verify lock: `Test Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`. - **Build and typecheck**, under the verify lock: `turbo run build` over `packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`; `@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck` holding 52 files / 246 errors / 135 pinned signatures, the same figures as stage 29; the 12 files are all in the `tsconfig.test.json` program. - **Gates.** `dispatch-gates.mjs --commands` at the head derives 79 (stage 29's 77 plus `check:authorable-surface` and `check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79 derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that keep their roster in a directory one of the paths is in (`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:generated` (all 15 artifacts up to date) also exit 0. - **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings. Population from ESLint's own config: 12 configured, 0 ignored, 0 with a type-aware parser option, so this diff cannot move the verdict of a file it does not touch. - **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in `packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files; controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json` present. The rewritten expect message occurs in 0 files of `dist/`; the control `Unrecognized key` occurs in 42. Nothing published changes. - **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of 5, not governed; 14 changed lines. - **Merge.** `git merge-tree --write-tree` onto `origin/main` `e75dceddd`: clean. - **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the changed files. Declared narrowing: the 12-file base and head comparison ran outside `os-verify-lock.sh`, after three queue turns (about 28 minutes) ended without a grant. It is a 12-file run with two workers; the workspace build, the typecheck and the full unit tier all ran under the lock. The gates are `check:*` runs, which do not use the lock. ## Acceptance notes - **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125, objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in files that already existed, one of them to a title objectstack-ai#22322 wrote while stripping a ruling date from a describe. Test files sit outside `check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so the per-stage census is the only instrument. An observation about the burn-down's denominator, not a class a / b / c finding. - **Comments are untouched.** Code comments in these files still cite ids (for example `// ─── assignment (objectstack-ai#14149) ───` at `builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22110 (body and all fifteen comments — the claim ① Derived judgmentsThis head against the PASS head
The whole PR's accept-set and public-surface changes, each confirmed at this head — the delta is the one the PASS record judged, re-read here rather than adopted:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS The three merges changed nothing this PR refuses or prescribes: its delta is byte-identical to the PASS-judged one, and in each of the seven files both sides edited, Generated by Claude Code |
…ow-text-slot-double-brace Conflict in packages/lint/src/validate-expressions.test.ts resolved as the union of both sides' additions to the PLUMBING receiver-excuse set (the branch's slot entry and main's five EvalUser member-list names); no case dropped, no assertion changed. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22110 (body and all sixteen comments — the claim ① Derived judgmentsThis head against the PASS head
The whole PR's accept-set and public-surface changes, each confirmed at this head — re-read here rather than adopted from the earlier PASS records:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS The merge changed nothing this PR refuses or prescribes: its delta is byte-identical to the one the PASS records judged, and in |
… merging main at 3ca71b6 (step 18: 64 conversions, 327 semantic entries) main added one step-18 semantic entry since bf492c8: flow-text-slot-single-brace-refused (#22315). At protocol 18 both generators project every step-18 entry, so both documents gain it, and the guide's 17 -> 18 intro paragraph carries its summary. The conversion ids are unchanged. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22110
Clause-②: yes (narrowing: the text slots' single-brace tokens are refused, while
{{ }}holes now render there and a$variable gains a hole spelling; corrected from the lossless-conversion reading by the seat in comment 6063190355)The line above is the claim's, copied verbatim as the dispatch requires. Its parenthetical predates the measurement: no spelling measured lossless, so this PR registers no D2 conversion (step 2 below). Every single-brace token in a text slot is refused with its remedy. The changeset carries the same value (
Clause-②: yes) without the parenthetical, so the published changelog does not state a conversion that does not exist. The direction is a narrowing, gradedmajoron the v18 pre line, and the changeset carries the ADR-0087 dispositionregistered flow-text-slot-single-brace-refused.The rulings this executes
{{ }}; single{ }deleted." (docs/adr/0032-unified-expression-layer.md:78). Its representation table names the text-template role:notify.title/body,titleFormat, email/notification.Measurement first
Step 1: the slots the single-brace interpolator reads (this repo, at the merge base
238222d8c)The census is a TypeScript-AST walk over
git ls-files '*.ts': every object literal carryingid,typeandconfig, every string leaf underconfig, with a nested node counted on its own. Source sites are listed below. Test sites are counted separately, and the census script is in the report.notify.titlestringifyForTemplate(interpolate(…))notify.messagescreen.descriptioninterpolateTextscreen.titleend.message(refused)interpolateTextnotify.recipients/recipients[]notify.sourceId·actionUrl·source.*screen.defaults.*·recordId· fielddefaultValuesubflow.input.*·script.inputs.*·map.input.*loop.collection·map.collectionhttp.*(whole config)*.filter.*interpolateFilter)create_record.fields.*·assignments.*{$User.Id}) · 0The five text slots are the positions whose rendered value is only ever text. Every other position hands its resolved value over with its type: a whole-string
{x}returns the raw value. The template engine always returns a string, so moving any of them would change what a value position accepts (the dispatch's value-slot stop condition). They keep the single brace. The interpolator serves no slot outside flows: it is not exported from@objectstack/service-automation, and the messaging and email renderers have their own{{ }}readers.In-tree text-slot sites:
examples/app-showcase(24 strings, 30 tokens) andexamples/app-todo(5 strings, 7 tokens). All 37 tokens are paths, one of them{$error.message}. The card's "46 in 2 files" counted a{record.…}pattern across all notify keys, includingrecipientsandsourceId.Step 2: the lossless check (H2)
Each spelling was rendered through the shipped interpolator (the notify path
stringifyForTemplate(interpolate(…)), and the screen /endpathinterpolateText) and through the shippedtemplateEngine.evaluateover the same variables, with{x}rewritten to{{ x }}. Abridged; the full 90-row grid is in the report.Hello {record.name}and{record.name},{record.owner.name},{rows.0},{summary},{n1.result}'', ISO date string, object, arrayDatevalue (what a CELnow()/today()assignment stores)"2026-10-08T09:30:00.000Z"(quotes included)2026-10-08T09:30:00.000Z{x}as a whole screen /endtextDate[object Object]·x,y·Thu Oct 08 2026 …{$error.message}$was not a hole character{n1}with onlyn1.resultset{"result":"r"}{amount * 2},{round(x)},{NOW()},{TODAY() + 1},{$User.Id}{{record.name}}{Acme}(outer braces kept)AcmeVerdict: no spelling is lossless. Every path spelling has a DIFF input (a
Date), the whole-slot screen /endcase also differs on objects and arrays, and the rest have no hole spelling. Per the dispatch's stop condition, no conversion is registered. Every spelling is refused with its remedy, and the D3 record is the semantic entryflow-text-slot-single-brace-refused. The card's second pin therefore reads as its "(when lossless)" allows: a storedHello {record.name}is refused at registration withHello {{ record.name }}named.The PM's hypotheses
builtin/template.ts(interpolateString, now around:357). The text slots it serves are notifytitle/message, screentitle/descriptionand the refusingendmessage. It reads many more positions than the card's "every other flow string slot" suggests, and all of those are value-like (the step 1 table).packages/formula/src/template-engine.ts, but no spelling renders the same on every input (step 2). It also could not read a$-named variable at all.NOTIFY_TEMPLATE_PLACEHOLDERis the one constant both notify refusals and the blank-envelope refusal read, and it flips to{{ record.name }}.EXPRESSION_SLOT_TYPESlacked the two internal constructors, and both are added. The staleTemplateExpressionInputSchemacomment innotify-node.tsis rewritten.flow-double-brace-interpolationwas the only rule teaching the single brace on these slots. It does not flip to flag single braces: the build door already refuses them aterrorthrough the spec's one judge, and a second reading would repeat it. Instead it exempts the text slots (a{{ }}there is the spelling), names them in its hint, and its bare-$sibling reads a text slot outside its holes with a hole prescription.What changes
Renderer (package-internal to
@objectstack/service-automation,builtin/template.ts; the package exports.only, so its public surface does not move):renderTextSlot(value, variables)is the ONE text renderer, used by notifytitle/message, screentitle/descriptionand theendrefusal message. It replacesinterpolateTextand notify'sstringifyForTemplate(interpolate(…)). It runstemplateEngine.evaluateovertextTemplateScope(variables): each flow variable is a root, a dotted node-output key is nested, a declared variable wins over a flat key sharing its head (the interpolator's precedence), and a variable's own object is never written into. If the slot renders no text at all the result isundefined, so a screen still falls back to its node label. A template that does not compile throwsFlowTextTemplateError, a guard refusal. A screen'srecordIdkeeps the single brace, through a localrefthat does whatinterpolateTextdid.Judge (
@objectstack/spec/automation, newflow-text-slot-template.ts):FLOW_NODE_TEXT_SLOTS,flowNodeTextSlotSources,textSlotTemplateRefusalandTEXT_SLOT_TEMPLATE_REFUSAL. It refuses every{…}the 17.x interpolator substituted, ignoring the inside of a{{ }}hole. Remedies by token kind:assignmentCEL envelope, keeping every integer divisor a double, then{{ v }};$User: anassignmentwhose value slot still reads that spelling (it is kept there, [v18] retire the{var}template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939), then{{ v }};Nothing is kept, so a text slot never mixes the two dialects.
Shared grammar: the interpolator's token grammar moved from
flow-value-slot-template.tsinto a package-internalflow-template-token.tsthat both judges import, so the dialect is read in one place. The value-slot judge's behaviour is unchanged, and its suites are green.Doors:
NotifyConfigSchema(inside its existingsuperRefine),ScreenConfigSchema(a newsuperRefine; one dropped-refinement site, ledgered 679 → 680) andEndConfigSchemarefuse a single brace at the key. Theendrefusal also lands atFlowSchema.parse.registerFlowandvalidateStackExpressionsrun the same judge, then compile each slot withvalidateExpression('template', …).{{ a + b }}, an unknown formatter or an unbalanced hole is refused before a run.@objectstack/formula:PATH_ONLY_REadmits$so that{{ $error.message }}is a hole. This is a widening of the hole grammar; an unbound$path renders nothing. Landing note: this file is outside the claim's surface. Measurement put the producer of the$errorgap here, and the fault-handler spelling had no{{ }}form without it.Contract text:
NOTIFY_TEMPLATE_PLACEHOLDERis now{{ record.name }}. The notify, screen and end.describe()s, the notify and screen descriptor help (the Studio inspector text), and the docblocks that taught the single brace (shared/expression.zod.ts,shared/typed-expression-input.ts,builtin-node-config.zod.ts,flow-node-expression-paths.ts) are rewritten.Ledger: the D3 semantic entry
flow-text-slot-single-brace-refusedand its step-18 rationale fragment (order 89). There is no D2 conversion and no tombstone, because no key is removed.In-tree sites: all 29 example strings, the docs (
content/docs/automation/flows.mdxgains a "Text slots read double-brace holes" section, pluscontent/docs/getting-started/common-patterns.mdxandpackages/services/service-automation/README.md), and the test fixtures that register flows (two dogfood fixtures and sevenservice-automationtest files) now use{{ }}.Tests and gates (at
11bdb8c96unless noted)Builds, tests and typechecks ran through
scripts/pm/os-verify-lock.sh, each read from itsVERDICTline. The box is shared.packages/spec/src/automation/flow-text-slot-template.test.ts: the slot list, every token kind's remedy, the hole and$paths passing, and the screen contract.packages/services/service-automation/src/builtin/text-slot-template.test.ts: the card's three pins.Hello {{ record.name }}renders the name.Hello {record.name}is refused at registration with its hole spelling.{{ record.name }} == 'Acme Corp'in an edge condition is refused at registration.{{ $error.message }}on a fault edge, a screen's holes beside a single-bracerecordId, and the renderer's measured SAME/DIFF rows.packages/lint/src/validate-expressions.text-slot.test.ts: the build door throughrunAuthoringRules('validate', …).$-path test, the lint double-brace / bare-$text-slot cases, and avalidate-flow-template-pathscase proving a typo inside a hole is still reported.@objectstack/service-automation177 files / 2171 passed;@objectstack/lint128 / 5850;@objectstack/formula43 / 1258;@objectstack/example-todo7 / 238;@objectstack/example-showcase33 / 408;@objectstack/dogfood7 named files / 58 (expression-conformance, the two schedule-organization files, seed-ownership-claim-dispatch, automation-flow-clone-door, flow-durable-suspend, flow-trigger-record-credential-mask);@objectstack/specsrc/automation src/shared src/conversions src/migrations84 / 2658, plus the repo-projectstep18-rationale-merge/conversions-major18-merge2 / 21. The non-spec suites were read atd54cc713d. The secondmainmerge (11bdb8c96) touched none of these packages' sources exceptpackages/spec/src/migrations/registry.ts(a sibling's step-18 entry), so the spec subset and the two merge tests were re-run there with the counts above.d54cc713d): spec, formula, service-automation, lint, dogfood, example-todo, example-showcase.pnpm --filter @objectstack/spec check:generatedreports all 15 current at11bdb8c96, afterpnpm --filter @objectstack/spec build(exit 0). Before that,--fixregeneratedapi-surface/,export-origins/andcontent/docs/references/automation/**.dropped-refinements.baseline.jsonwas hand-edited (one site).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 119 commands atd54cc713d, the same list as atf94f92250. All 119 ran with their exit codes captured before any pipe. 118 answered exit 0 on the first pass atf94f92250.check:doc-authoringanswered exit 1: two#22110ids in the dogfood ledger's prose. Those ids were stripped (d54cc713d) and the gate re-ran with exit 0, alongsidecheck:nul-bytes,check:cross-package-test-inputs,check:issue-citationsandcheck:published-readme-links. The--ranreconciliation reports: "119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED".check-changeset-no-majorreads the Clause-② axis from a PR payload, so locally that axis is NOT MEASURED (no PR); CI reads this body. After the second merge,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:migration-registry,check:spec-changes,check:upgrade-guideandcheck:nul-byteswere re-run at11bdb8c96(exit 0 each), together with the 15-artifactcheck:generated.trigger-record-change,plugin-approvals,runtime,cli,metadata-protocol, the rest ofdogfood) were not run locally. They carry no single-brace text-slot site by the census and a line grep. CI runs them.Acceptance notes
skills/**(Tier H, not in this PR): these files now teach a spelling the doors refuse.skills/objectstack-automation/SKILL.md:108-109(a notifytitle/messageexample in{record.title}/{$User.Id}).skills/objectstack-automation/SKILL.md:224-236(lists "notifymessage/title" among the single-brace values, and marks{{ }}as wrong).skills/objectstack-automation/evals/flows-triggers-approvals.json:17(expects "titlewith single-brace interpolation").AutomationEngine.celScopenests a flat dotted key (n1.result) into an existing variable's own object, which mutates the flow variable. The text renderer builds fresh containers instead. No defect was reproduced; no carrier.extra.locale, so a flow variable namedlocalesets the text slots' formatter locale. This is documented on the package-internal text renderer.validate-flow-template-pathsjudges the path of a{{ path | formatter }}hole like a bare one (patch round 2), and a bracket-indexed path ({{ rows[0].subjcet }}) with each[i]read as.i, the way the formula engine'sresolvePathresolves it (patch round 3). A quoted index (['x']) stays unjudged, as an arithmetic token.packages/services/service-automation/README.md's expressions table still says field values interpolate with braces, which [v18] retire the{var}template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 made false. That row is not this card's.Patch round 2 (contract review FAIL
6063558021→ seat order6063702421→0b094026d)Written into this body by the
domain:specseat 2 from the dev's report6065199188; the role file reserves a later body edit to the seat.packages/spec/src/shared/expression.zod.ts: theTemplateExpressionInputSchemadocblock's closing sentence names{{var}}for a notify node'stitle/messagetoo. The{var}clause is gone. New pin:src/shared/template-expression-input-canon.test.ts.packages/spec/src/automation/flow-text-slot-template.ts:singleBraceTokensdrops a token that touches a brace on either side. A hole missing one brace (Total {{ amount },Total { amount }}) gets no rewrite from the judge, and the compile step every door runs next reports it as an unbalanced template (invalid-templateatconfig.title, pinned at theobjectstack validatedoor with no three-brace text in the message).packages/lint/src/validate-flow-template-paths.tstemplateRefsInand its docblock: inside a{{ }}hole, the path before an optional| formatteris judged like a bare one. The docblock describes both dialects, because the single-brace interpolator still renders the value-like positions (interpolate); this PR deleted onlyinterpolateText.renderTextSlot/FlowTextTemplateError.template-expression-input-canon.test.ts; (2) reddensflow-text-slot-template.test.ts > prescribes no rewrite for a token touching exactly one brace; (3) reddensvalidate-flow-template-paths.test.ts > judges the path of a {{ path | formatter }} hole like a bare one.0b094026d: specsrc/automation+src/shared70 files / 2020 passed; lint whole suite 128 files / 5852 passed; service-automation text-slot tests 4 files / 46 passed; typecheck exit 0 for spec and lint;check:generated15 / 15 current.dispatch-gates --ran: 119 derived, 119 run, 0 NOT-MEASURED.check-changeset-no-major --event:LEVEL AXIS … yes (narrowing).check-adr-0087-registration:registered flow-text-slot-single-brace-refused.Patch round 3 (contract review FAIL
6065484352→ seat order6065501303→5bfa9ae1f)Written into this body by the
domain:specseat 2 at 2026-10-08T20:27Z from the dev's report6068392390(the comments before it on this PR are read: the two contract reviews are what rounds 2 and 3 answer, and the CI note6067723298is carried into the CI line below); the role file reserves a later body edit to the seat.171cea002, the four items:packages/spec/src/shared/expression.zod.ts, thetmpldocblock: the notify bullet that prescribed{record.x}is gone. The{{record.x}}bullet says that since protocol 18 a notify node'stitle/messageread{{record.x}}, and that a single brace is refused at every door.TYPED_EXPRESSION_SOURCE_REQUIRED's docblock says the notify slots prescribe the same{{ }}hole.template-expression-input-canon.test.tsholds the three docblocks with one predicate. The spec-wide sweep found no other docblock or describe that prescribes{var}for a notify, screen orendtext slot.packages/lint/src/validate-flow-template-paths.tstemplateRefsInreads[i]as.iinside a hole, with one finding pin and one no-finding pin.c673c8635: the bracket pin spreads a typed record, socheck:test-typecheckholds. This repaired theType Check · workspacered at171cea002.b5d3cd532:expression-dialect-docs.pin.test.tspinned the oldtmplrelation, which item (1) necessarily turned red. It now pins the protocol-18 relation.0788b58e1/5bfa9ae1f:origin/main3599fef12merged viaos-regen-merge.sh. The only conflict wasservice-automationengine.ts's import block, resolved as the union. The regeneration restores main'sbuiltinNodeConfigKeysJudgedexport beside this branch's text-slot exports. Range-diff: commits 1–16 show=. The PR's line multiset is identical before and after the merge (54 files, 2261 lines).tmplpins (2 failed / 10 passed); (2) 1 failed / 5 passed; (4) 1 failed / 63 passed. The CI repro (lint test typecheck) exits 1.5bfa9ae1f, after a full build (73 / 73):src/shared+automation+migrations: 74 files / 2239 passed;check:generated: 15 / 15 current;dispatch-gates --ran: 119 derived, 119 run, 0 NOT-MEASURED;check-changeset-no-major --event: exit 0 (narrowing arm);check-adr-0087-registration: exit 0.5bfa9ae1f(seat note6067723298):Test Core (6/6)is the timing-drift step (finding(ci): the shard-timings dataset rests on ONE scheduled run, and records @objectstack/spec at 1134.86 s against 1573–1651 s executed — #16468's 25%-headroom ceilings built on it would red every PR that runs spec #22014), not a test.Test Core (2/6)isconnector-author-shape.test.ts:194at its 60 s budget, the margin hourly full run: red on main (CI) #22292 records on main. Interleaved runs, head vs main3599fef12: 19.3 / 25.2 / 26.9 s against 23.5 / 27.6 / 24.9 s. tsc on the test's probes: +0.03% instantiations, equal check time. The head is not slower.Generated by Claude Code