Skip to content

build: objectstack validate / compile accept unknown keys in a plugin node's config (e.g. an approval node's escalation) — the build-time refusal map covers built-in node types only #21850

Description

@objectstack-fleet

QA-source: #21845 · approvals.sla-escalation · acceptance[4]

Clause A5 of approvals.sla-escalation (rev 2) fails in the follow-up run #21845 (subject 316be321e); an independent verifier (RUNNER rule 7) reproduced it: low-medium, predates 17.6.0.

Reproduction

  1. In a scratch copy of the showcase, add a flow whose approval node has escalation {…, bogusKey: 1} (or the timeout alias).
  2. objectstack validate → exit 0; objectstack compile → exit 0, and the key is copied into dist/objectstack.json.
  3. Boot: the flow is dropped with only a WARN; the server stays healthy.

Mechanism

packages/spec/src/automation/flow-node-config-refusals.ts:94-111 holds config contracts for the 13 built-in node types; plugin node types (approval) are not consulted at build time. The timeout alias is correctly refused (aliases only drive the "did you mean" text).

Done when

The build consults registered plugin node descriptors (or a declared contract map) and refuses unknown keys and invalid values; a test pins the approval escalation case.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: the road — start: the build is the author's first gate | approvals.sla-escalation (the build door) | P1

    Triage: first grade — bug · priority:p3 · domain:spec · area:workflow · pm:queue. The build's node-config refusal map covers the plugin node types the spec declares

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T09:00Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/spec/src/automation/flow-node-config-refusals.ts (about :94–:111) ⇒ domain:spec; rationale: the map holds the 13 built-in node types' contracts, and the approval node's config contract is declared in the spec too (approval.zod.ts).


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
    Session: session_018zT8d8NpiQ1ExhuNd5TxY6
    Account: hotlong (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-21850-build-plugin-node-config
    Worktree: objectstack-issue-21850
    Domain: domain:spec (card label, as triage set it)
    File surface: packages/spec/src/automation, packages/cli/src, .changeset/
    Container & model: M, mode:subagent, model: opus (default tier; no path-derived mandate)
    Clause-②: no
    Thread-read: 5991330471
    Serial constraints cleared: none named

    Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「都开单派发」; landing per the standing order 「开发完整就进队列合并」. Dispatched one at a time within the session's load cap. Where the card withholds detail, this session holds it and the dispatch carries it privately.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Release: session session_018zT8d8NpiQ1ExhuNd5TxY6 · cause: re-route before dispatch (no dev was started on this card) · destination: back to pm:queue for the domain:spec lane seat to claim.

    Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「只有不能公开的才需要你派发处理」. This card withholds nothing, so its dispatch belongs to the lane. The claim above is withdrawn, the assignee is cleared, and the triage grading stands.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (this card, per triage's first grade 5991330471; released to the lane by 5995791056) · 2026-10-05T13:58Z
    Session: session_01T9u38rswFp5Rw8DswRUReJ
    Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-21850-approval-node-config-build-refusal
    Worktree: objectstack-issue-21850
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    File surface (at origin/main 5e0b489bca; stop on breach and explain in the report):

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21850,
      "status": "blocked",
      "branch": "claude/issue-21850-approval-node-config-build-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21893",
      "session": "session_01T9u38rswFp5Rw8DswRUReJ",
      "premise_still_valid": true,
      "summary": "Reproduced on 5e0b489bca: escalation.bogusKey and escalation.timeoutHours 0.5 each made objectstack validate and compile exit 0, and compile wrote them into dist/objectstack.json. On the branch, FlowSchema, defineStack, the stack parse validate and compile run, the registered flow type schema and the artifact parse all refuse both, at nodes.N.config.escalation.KEY; the CLI exits validate 1 and compile 2. A valid approval node still passes as the control, and the alias did-you-mean text is carried through. Two dispatch-route assumptions failed when measured, so the route changed. (1) Putting approval into getBuiltinNodeConfigContracts turned the service-automation executor-to-map ledger red (2 of 5 tests); that ledger is domain:services. (2) The judge keeps only missing-key issues, so with approval in the map both card pins still parsed. So the approval contract is in a module-private declared map (getDeclaredPluginNodeConfigContracts) next to the builtin one. The same judge, flowNodeConfigRefusals, reads it and judges it WHOLE. An undeclared key or a refused value gets the new closed-set code node-config-refused-by-contract, anchored at the key. A missing required key keeps node-config-key-missing or node-config-key-required-by-rule. The builtin arm is unchanged and still presence-only. For #21848 (per the coordinator FYI): getBuiltinNodeConfigContracts keeps its export, shape and 13 builtin entries. A caller of that map gets undefined for approval. The approval contract is reached through the exported flowNodeConfigRefusals. Because registerFlow runs FlowSchema.parse first (engine.ts:4348), registration now refuses approval values too. The ADR-0087 kit uses step-18 rationale order 84, re-read on origin/main e085a8c3be right before pr_create (highest there: 83). It has a D3 entry flow-approval-node-config-contract-refused and a BREAKING minor changeset with the registered marker. BLOCKED on one line: packages/services/service-automation/src/engine.test.ts, where the baseFlow approval helper has no approvers. That is the only red test in the suites I ran (service-automation 2109/2110), and the dispatch forbids this PR from editing domain:services files.",
      "tests": "All at HEAD 3fc48ab07f. pnpm --filter @objectstack/spec test: 617 files, 18447 passed, exit 0. pnpm --filter @objectstack/spec typecheck: exit 0. pnpm --filter @objectstack/lint test, before the fixture fix: 2 failed, both runtime-gate.test.ts (the clean fixture carried emptyApproverPolicy). After the fix, runtime-gate.test.ts: 46/46. metadata-protocol, the 4 approval-fixture files: 1 failed (9 tests) before the fix; protocol.runtime-authoring-gate.test.ts 70/70 after. objectql, the 4 approval-fixture files: 1 failed before the fix; plugin.authoring-channel.test.ts 6/6 after. pnpm --filter @objectstack/plugin-approvals test: 60 files, 895/895. pnpm --filter @objectstack/service-automation test: 1 failed of 2110. The failure is engine.test.ts \"says nothing about a type a plugin registered AFTER the flow\": ZodError nodes.1.config.approvers from registerFlow then FlowSchema.parse (the blocked item). cli test/authoring-rule-command-parity.test.ts (integration tier, where it lives): 11/11. typecheck exit 0 for lint, objectql and metadata-protocol. CLI door repro (scratch showcase copy, dynamic-approval co_sign node, edit proven on disk and restored byte-identical). Main: bogusKey and halfHour each give validate 0 (\"Validation passed\") and compile 0 (\"Build complete\"), and the artifact carries them. Branch: validate 1 and compile 2, custom at nodes.2.config.escalation.bogusKey or .timeoutHours. Control: 0/0 on both trees. Ablation: fix committed first; scripts/ablation-replace.mjs deleted the approval entry (anchor 1 to 0, blob f915eb58bcd0 to 90e86f48dcc4). The subject resolves through src by relative import, so there was no build or dist step. Predicted 14 red (12 in the new file plus 2 in flow-slot-refusal-codes); observed \"Tests 14 failed | 26 passed (40)\". Restore: blob equals HEAD and git diff HEAD is empty. Mechanism probe on BASE (approval inserted into the builtin map, spec rebuilt, dist preflight marker present in 20 files): service-automation node-config-contract-ledger.test.ts 2 failed of 5; FlowSchema bogusKey true and halfHour true (still accepted). Restored by blob hash, then spec was rebuilt from the implementation. check:generated: all 15 artifacts up to date. ESLint narrowed to the changed files: the population comes from eslint isPathIgnored (11 of 11 linted); --format json reports files 11, errors 0, warnings 0; the config has no type-aware linting, so untouched files are invariant.",
      "mcp_calls": "1 — mcp__claude-code-remote__add_repo (objectstack-ai/hotcrm, access read; it attached nothing). No GitHub MCP tool was called, so no write tool.",
      "api_writes": "3 relay strokes, each ONE repository_dispatch to the fleet-write relay. (1) pr_create, which became POST /repos/objectstack-ai/objectstack/pulls (draft forced), giving PR 21893; body read back identical, 13794 bytes. (2) label-write --assign os-project-manager, which became POST /repos/objectstack-ai/objectstack/issues/21893/assignees; read back as matching. (3) this os-dev-report comment, which becomes POST /repos/objectstack-ai/objectstack/issues/21850/comments. git push is not counted.",
      "open_questions": [
        {
          "question": "Who makes the one-line fixture change in packages/services/service-automation/src/engine.test.ts that this PR needs to go green? In the baseFlow helper (line 2623), the approval node needs config { approvers: [{ type: 'user', value: 'u1' }] } when type is approval. The dispatch forbids domain:services edits here.",
          "options": [
            "A: the PM amends this card's file surface to cover that one line, and a patch round on this branch adds it.",
            "B: the domain:services seat adds it in the #21848 PR, and this PR lands after it or together with it.",
            "C: the services seat lands it as a standalone test-only PR first."
          ],
          "recommendation": "A. The line is forced by this card's narrowing, and it is the same disposition already used on the spec fixture pausingNode. The one caveat: if #21848 is touching engine.test.ts right now, B avoids a textual collision."
        },
        {
          "question": "Does #21848 need re-scoping? registerFlow parses with FlowSchema first, so it now refuses approval values such as timeoutHours 0.5, at nodes.N.config.escalation.timeoutHours.",
          "options": [
            "A: #21848 keeps only package-load paths that do not parse FlowSchema, plus its own registration pins, and reuses flowNodeConfigRefusals.",
            "B: #21848 stays as filed."
          ],
          "recommendation": "A. The judge is already the one lookup, and building a second one would fork the contract."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) · noted, not filed — objectui flow inspector (pin 0abd4f9f87, flow-node-config.ts:996): the escalation.timeoutHours field shows only while escalation.enabled is true, so switching SLA escalation off can save escalation { enabled: false } with no timeoutHours. The contract refuses that, and the executor already refused it on every run; it is now refused at save. This is inferred from source and was not driven in a designer.",
        "carrier: none (承接者:无) · noted, not filed — when defineFlow throws during the CLI config load, the CLI prints the raw ZodError JSON, with a path relative to the flow and no flow name or file. This is existing behaviour for every defineFlow refusal.",
        "carrier: #21848 PR (domain:services seat) · noted, not filed — nothing reconciles the plugin-approvals executor safeParse against the declared contract map, the way node-config-contract-ledger.test.ts does for builtins. That ratchet would live in plugin-approvals."
      ],
      "gates": {
        "node scripts/check-adr-0087-registration.mjs --base origin/main": 0,
        "node scripts/check-adr-0087-registration.mjs --self-test": 0,
        "node scripts/check-changeset-no-major.mjs --base origin/main": 0,
        "node scripts/check-changeset-no-major.mjs --self-test": 0,
        "node scripts/check-ci-filter-parity.mjs": 0,
        "node scripts/check-closing-keyword-parity.mjs": 0,
        "node scripts/check-closing-keyword-parity.mjs --self-test": 0,
        "node scripts/check-comment-mask-adoption.mjs": 0,
        "node scripts/check-comment-mask-adoption.mjs --self-test": 0,
        "node scripts/check-comment-mask-corpus.mjs": 0,
        "node scripts/check-dev-prereqs.mjs --self-test": 0,
        "node scripts/check-dts-emitted.mjs --self-test": 0,
        "node scripts/check-empty-changeset.mjs --base origin/main": 0,
        "node scripts/check-empty-changeset.mjs --self-test": 0,
        "node scripts/check-engine-split-ratio.mjs --days 90": 2,
        "node scripts/check-engine-split-ratio.mjs --self-test": 0,
        "node scripts/check-issue-citations.mjs": 0,
        "node scripts/check-keyed-text-bounds.mjs": 0,
        "node scripts/check-keyed-text-bounds.mjs --self-test": 0,
        "node scripts/check-platform-object-tenancy-census.mjs": 0,
        "node scripts/check-platform-object-tenancy-census.mjs --self-test": 0,
        "node scripts/check-plugin-teardown-shape.mjs": 0,
        "node scripts/check-plugin-teardown-shape.mjs --self-test": 0,
        "node scripts/check-registry-log-declared.mjs": 0,
        "node scripts/check-registry-log-declared.mjs --self-test": 0,
        "node scripts/check-rest-log-spy-declared.mjs": 0,
        "node scripts/check-rest-log-spy-declared.mjs --self-test": 0,
        "node scripts/check-spec-docblock-symbol-anchors.mjs": 0,
        "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test": 0,
        "node scripts/check-system-context-census.mjs": 0,
        "node scripts/check-system-context-census.mjs --self-test": 0,
        "node scripts/check-undeclared-dep-imports.mjs": 0,
        "node scripts/check-undeclared-dep-imports.mjs --self-test": 0,
        "node scripts/docs-audit/check-affected-docs.mjs": 0,
        "node scripts/docs-audit/check-drift-comment.mjs": 0,
        "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
        "node scripts/release-pending-publish.mjs --self-test": 0,
        "pnpm --filter @objectstack/lint run check:doc-formula-expressions": 0,
        "pnpm --filter @objectstack/spec run check:api-surface": 0,
        "pnpm --filter @objectstack/spec run check:authorable-surface": 0,
        "pnpm --filter @objectstack/spec run check:browser-reachable-entries": 0,
        "pnpm --filter @objectstack/spec run check:docs": 0,
        "pnpm --filter @objectstack/spec run check:dual-source-exports": 0,
        "pnpm --filter @objectstack/spec run check:duration-unit-keys": 0,
        "pnpm --filter @objectstack/spec run check:empty-state": 0,
        "pnpm --filter @objectstack/spec run check:entry-nameability": 0,
        "pnpm --filter @objectstack/spec run check:export-origins": 0,
        "pnpm --filter @objectstack/spec run check:exported-any": 0,
        "pnpm --filter @objectstack/spec run check:liveness": 0,
        "pnpm --filter @objectstack/spec run check:llms-txt": 0,
        "pnpm --filter @objectstack/spec run check:migration-registry": 0,
        "pnpm --filter @objectstack/spec run check:objectui-pin-citations": 0,
        "pnpm --filter @objectstack/spec run check:skill-refs": 0,
        "pnpm --filter @objectstack/spec run check:spec-changes": 0,
        "pnpm --filter @objectstack/spec run check:strictness-ledger": 0,
        "pnpm --filter @objectstack/spec run check:upgrade-guide": 0,
        "pnpm --filter @objectstack/spec run check:variant-docs": 0,
        "pnpm --filter @objectstack/spec run check:yaml-examples": 0,
        "pnpm check:changeset-gate-self-tests": 0,
        "pnpm check:cross-package-test-inputs": 0,
        "pnpm check:dispatcher-error-vocabulary": 0,
        "pnpm check:doc-authoring": 0,
        "pnpm check:docs-transcript-drift": 0,
        "pnpm check:driver-memory-census": 0,
        "pnpm check:dts-closure": 0,
        "pnpm check:dual-build-cjs-loads": 3,
        "pnpm check:durability-log-level": 0,
        "pnpm check:engine-double-contract": 0,
        "pnpm check:future-spec-major": 0,
        "pnpm check:gitlink-declared": 0,
        "pnpm check:issue-citations": 0,
        "pnpm check:lean-entry-closure": 0,
        "pnpm check:logger-receiver-detach": 0,
        "pnpm check:merge-driver": 0,
        "pnpm check:nul-bytes": 0,
        "pnpm check:objectql-double-limit": 0,
        "pnpm check:objectui-changeset": 0,
        "pnpm check:org-identifier": 0,
        "pnpm check:page-declaration-shape": 0,
        "pnpm check:pm-changeset-deadline-census": 0,
        "pnpm check:pm-prior-rulings": 0,
        "pnpm check:pm-widening-tells": 0,
        "pnpm check:published-files": 0,
        "pnpm check:query-options-erasure": 0,
        "pnpm check:refd-timer-probe": 0,
        "pnpm check:slot-lookup": 0,
        "pnpm check:sourcemap-no-sources-content": 0,
        "pnpm check:spec-parsed-alias": 0,
        "pnpm check:test-source-alias": 0,
        "pnpm check:tier-file-adoption": 0,
        "pnpm check:type-check-coverage": 0,
        "pnpm check:watch-hint-literal": 0,
        "pnpm check:where-matcher": 0,
        "pnpm check:type-check-debt": 0
      },
      "gates_not_measured": {
        "pnpm check:dual-build-cjs-loads": "exit 3 PREREQUISITE NOT MET: 8 unrelated packages have no dist/ (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge)",
        "node scripts/check-engine-split-ratio.mjs --days 90": "exit 2: refused on a shallow clone (oldest visible commit 2026-09-20, inside the 90-day window); the reconciliation counts it as run",
        "pnpm check:type-check-debt (first run)": "exit 3 NOT MEASURED: a concurrent build of @objectstack/metadata produced TS2307 for @objectstack/metadata/errors. The re-run was green and is the recorded 0."
      },
      "gates_reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list exit 0: \"94 derived famil(ies) accounted for — 93 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)\", 0 UNRUN",
      "deviations": [
        "Route: approval is NOT in getBuiltinNodeConfigContracts, as the dispatch said it should be. Measured: the service-automation ledger (domain:services) went red at 2 of 5 tests with it there. It is in a module-private sibling map read by the same judge. The builtin map docblock stays accurate and now points at the sibling map.",
        "Mechanism: the judge is presence-only for builtins, so a whole-judged arm was added for the declared plugin contract, together with a new closed-set code node-config-refused-by-contract.",
        "File surface beyond the claim, declared: packages/spec/src/automation/flow-node-expression-paths.ts (code table), flow.zod.ts (comment only), flow-region-pause-and-end.test.ts and flow-slot-refusal-codes.test.ts (spec fixtures and pins), packages/lint/src/runtime-gate.test.ts (domain:spec), packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts and packages/objectql/src/plugin.authoring-channel.test.ts (domain:engine). In the last three, the undeclared key emptyApproverPolicy was deleted from the clean fixture.",
        "Not done, because the dispatch forbids domain:services edits: the one-line fixture in packages/services/service-automation/src/engine.test.ts. This is the blocked item.",
        "hotcrm census NOT MEASURED. Command: GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 https://github.com/objectstack-ai/hotcrm SCRATCH/hotcrm. Refusal: \"Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Untrusted Code Integration]\". Not retried by any other route.",
        "flow-slot-refusal-codes.test.ts pins the new code's message text, following that file's existing per-code message-pin convention.",
        "No merge of main before pr_create: re-fetched origin/main e085a8c3be brought 3 commits (#21874, #21881, #21882), and none touches a file of this PR."
      ],
      "files_changed": [
        ".changeset/21850-flow-approval-node-config-contract-refused.md",
        "packages/lint/src/runtime-gate.test.ts",
        "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts",
        "packages/objectql/src/plugin.authoring-channel.test.ts",
        "packages/spec/src/automation/flow-approval-node-config-contract.test.ts",
        "packages/spec/src/automation/flow-node-config-refusals.ts",
        "packages/spec/src/automation/flow-node-expression-paths.ts",
        "packages/spec/src/automation/flow-region-pause-and-end.test.ts",
        "packages/spec/src/automation/flow-slot-refusal-codes.test.ts",
        "packages/spec/src/automation/flow.zod.ts",
        "packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts",
        "packages/spec/src/migrations/registry.ts"
      ],
      "line_budget": {
        "additions": 676,
        "deletions": 12,
        "shortstat": "12 files changed, 676 insertions(+), 12 deletions(-)"
      },
      "rationale_order_taken": 84,
      "builtin_map_for_approval": "getBuiltinNodeConfigContracts().get('approval') returns undefined. The export, shape and 13 entries are unchanged.",
      "cleanup": "worktree node_modules removed and the worktree removed after the report (see the final message)"
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim revision (file surface, round 1) of claim 5995983674: same session, account, branch, worktree, domain and seat · 2026-10-05T15:42Z

    The report 5997820514 (draft PR #21893) measured two route facts that moved the change off the claimed shape.

    1. Adding approval to getBuiltinNodeConfigContracts turns domain:services' executor-to-map ledger (service-automation node-config-contract-ledger.test.ts) red, 2 of 5.
    2. That map's judge keeps missing-key issues only, so both card pins still parsed.

    So the approval contract sits in a module-private sibling map. The same judge, flowNodeConfigRefusals, reads it whole, and a new closed-set code node-config-refused-by-contract is anchored at the refused key. getBuiltinNodeConfigContracts keeps its export, shape and 13 entries, and the builtin arm is unchanged. The at-tier review judges this route and the new code.

    File surface, added:

  7. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21850,
      "report_kind": "addendum — patch round 1 (claim revision 5997870685; open question 1 answered A)",
      "status": "done",
      "branch": "claude/issue-21850-approval-node-config-build-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21893",
      "head": "76118d27fe2ed2592da573f73a23c186dc14e063",
      "previous_head": "3fc48ab07f1a11886f07d0c8a8187c51bfd92961",
      "session": "session_01T9u38rswFp5Rw8DswRUReJ",
      "premise_still_valid": true,
      "summary": "Patch round 1 is one test line. In packages/services/service-automation/src/engine.test.ts, the baseFlow helper now gives an approval node config { approvers: [{ type: 'user', value: 'u1' }] }, the minimal shape its contract requires. No other line changed, and no service-automation source changed. The full service-automation suite now passes 2110/2110. The CI-reported objectql failure on 3fc48ab07f did not reproduce: the full objectql suite passes 7464/7464 at the new head, and objectql source is unchanged between the two heads. The full http-conformance suite, which CI scheduled but never reached, passes 102/102. check:dual-build-cjs-loads went from NOT MEASURED to exit 0 once a full build supplied the 8 missing dists. origin/main was re-fetched at 67c544ccca. It has 5 commits past BASE and none touches any of the 13 files, so there was no merge. Step-18 rationale order 84 is still free there (highest 83; this id absent), so 84 is kept. The PR body is stale (it still reads as blocked, and the verification is at 3fc48ab07f), so pr_body_replacement is attached for the seat to patch.",
      "tests": "All runs at HEAD 76118d27fe, each exit recorded to a file before it was read. pnpm --filter @objectstack/service-automation test (verify lock): EXIT=0, Test Files 172 passed (172), Tests 2110 passed (2110). pnpm --filter @objectstack/service-automation typecheck: exit 0. pnpm --filter @objectstack/objectql test (verify lock): EXIT=0, Test Files 374 passed (374), Tests 7464 passed (7464). pnpm --filter @objectstack/http-conformance test (verify lock): EXIT=0, Test Files 8 passed (8), Tests 102 passed (102). pnpm check:dual-build-cjs-loads: EXIT=0, \"106 published require entry point(s) across 66 package(s) load; 712 emitted CommonJS file(s) parse\". It ran after turbo build of everything but docs (72 tasks, 56 cached), and all 8 previously missing dists were confirmed present. The spec suite was not re-run: no spec file changed since 3fc48ab07f (617 files / 18447 passed there). ESLint on the 12 changed .ts files: isPathIgnored says linted for engine.test.ts too; --format json reports files 12, errors 0, warnings 0; no type-aware linting.",
      "service_automation_reading": "2110 / 2110 (172 files), exit 0, at 76118d27fe",
      "mcp_calls": "0 this round — no MCP tool was called.",
      "api_writes": "1 this round: this os-dev-report addendum, one fleet-write relay stroke, which becomes POST /repos/objectstack-ai/objectstack/issues/21850/comments. git push 3fc48ab07f..76118d27fe is not counted. PR not flipped ready, no auto-merge, not enqueued.",
      "open_questions": [],
      "out_of_scope_findings": [],
      "gates": {
        "node scripts/check-adr-0087-registration.mjs --base origin/main": 0,
        "node scripts/check-adr-0087-registration.mjs --self-test": 0,
        "node scripts/check-changeset-no-major.mjs --base origin/main": 0,
        "node scripts/check-changeset-no-major.mjs --self-test": 0,
        "node scripts/check-ci-filter-parity.mjs": 0,
        "node scripts/check-closing-keyword-parity.mjs": 0,
        "node scripts/check-closing-keyword-parity.mjs --self-test": 0,
        "node scripts/check-comment-mask-adoption.mjs": 0,
        "node scripts/check-comment-mask-adoption.mjs --self-test": 0,
        "node scripts/check-comment-mask-corpus.mjs": 0,
        "node scripts/check-dev-prereqs.mjs --self-test": 0,
        "node scripts/check-dts-emitted.mjs --self-test": 0,
        "node scripts/check-empty-changeset.mjs --base origin/main": 0,
        "node scripts/check-empty-changeset.mjs --self-test": 0,
        "node scripts/check-engine-split-ratio.mjs --days 90": 2,
        "node scripts/check-engine-split-ratio.mjs --self-test": 0,
        "node scripts/check-issue-citations.mjs": 0,
        "node scripts/check-keyed-text-bounds.mjs": 0,
        "node scripts/check-keyed-text-bounds.mjs --self-test": 0,
        "node scripts/check-platform-object-tenancy-census.mjs": 0,
        "node scripts/check-platform-object-tenancy-census.mjs --self-test": 0,
        "node scripts/check-plugin-teardown-shape.mjs": 0,
        "node scripts/check-plugin-teardown-shape.mjs --self-test": 0,
        "node scripts/check-registry-log-declared.mjs": 0,
        "node scripts/check-registry-log-declared.mjs --self-test": 0,
        "node scripts/check-rest-log-spy-declared.mjs": 0,
        "node scripts/check-rest-log-spy-declared.mjs --self-test": 0,
        "node scripts/check-spec-docblock-symbol-anchors.mjs": 0,
        "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test": 0,
        "node scripts/check-system-context-census.mjs": 0,
        "node scripts/check-system-context-census.mjs --self-test": 0,
        "node scripts/check-tenant-audit-census.mjs": 0,
        "node scripts/check-tenant-audit-census.mjs --self-test": 0,
        "node scripts/check-undeclared-dep-imports.mjs": 0,
        "node scripts/check-undeclared-dep-imports.mjs --self-test": 0,
        "node scripts/docs-audit/check-affected-docs.mjs": 0,
        "node scripts/docs-audit/check-drift-comment.mjs": 0,
        "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
        "node scripts/release-pending-publish.mjs --self-test": 0,
        "pnpm --filter @objectstack/lint run check:doc-formula-expressions": 0,
        "pnpm --filter @objectstack/spec run check:api-surface": 0,
        "pnpm --filter @objectstack/spec run check:authorable-surface": 0,
        "pnpm --filter @objectstack/spec run check:browser-reachable-entries": 0,
        "pnpm --filter @objectstack/spec run check:docs": 0,
        "pnpm --filter @objectstack/spec run check:dual-source-exports": 0,
        "pnpm --filter @objectstack/spec run check:duration-unit-keys": 0,
        "pnpm --filter @objectstack/spec run check:empty-state": 0,
        "pnpm --filter @objectstack/spec run check:entry-nameability": 0,
        "pnpm --filter @objectstack/spec run check:export-origins": 0,
        "pnpm --filter @objectstack/spec run check:exported-any": 0,
        "pnpm --filter @objectstack/spec run check:liveness": 0,
        "pnpm --filter @objectstack/spec run check:llms-txt": 0,
        "pnpm --filter @objectstack/spec run check:migration-registry": 0,
        "pnpm --filter @objectstack/spec run check:objectui-pin-citations": 0,
        "pnpm --filter @objectstack/spec run check:skill-refs": 0,
        "pnpm --filter @objectstack/spec run check:spec-changes": 0,
        "pnpm --filter @objectstack/spec run check:strictness-ledger": 0,
        "pnpm --filter @objectstack/spec run check:upgrade-guide": 0,
        "pnpm --filter @objectstack/spec run check:variant-docs": 0,
        "pnpm --filter @objectstack/spec run check:yaml-examples": 0,
        "pnpm check:changeset-gate-self-tests": 0,
        "pnpm check:cross-package-test-inputs": 0,
        "pnpm check:dispatcher-error-vocabulary": 0,
        "pnpm check:doc-authoring": 0,
        "pnpm check:docs-transcript-drift": 0,
        "pnpm check:driver-memory-census": 0,
        "pnpm check:dts-closure": 0,
        "pnpm check:dual-build-cjs-loads": 0,
        "pnpm check:durability-log-level": 0,
        "pnpm check:engine-double-contract": 0,
        "pnpm check:future-spec-major": 0,
        "pnpm check:gitlink-declared": 0,
        "pnpm check:issue-citations": 0,
        "pnpm check:lean-entry-closure": 0,
        "pnpm check:logger-receiver-detach": 0,
        "pnpm check:merge-driver": 0,
        "pnpm check:nul-bytes": 0,
        "pnpm check:objectql-double-limit": 0,
        "pnpm check:objectui-changeset": 0,
        "pnpm check:org-identifier": 0,
        "pnpm check:page-declaration-shape": 0,
        "pnpm check:pm-changeset-deadline-census": 0,
        "pnpm check:pm-prior-rulings": 0,
        "pnpm check:pm-widening-tells": 0,
        "pnpm check:published-files": 0,
        "pnpm check:query-options-erasure": 0,
        "pnpm check:refd-timer-probe": 0,
        "pnpm check:slot-lookup": 0,
        "pnpm check:sourcemap-no-sources-content": 0,
        "pnpm check:spec-parsed-alias": 0,
        "pnpm check:test-source-alias": 0,
        "pnpm check:tier-file-adoption": 0,
        "pnpm check:type-check-coverage": 0,
        "pnpm check:watch-hint-literal": 0,
        "pnpm check:where-matcher": 0,
        "pnpm check:type-check-debt": 0
      },
      "gates_reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran-r1.list exit 0 at 76118d27fe: \"96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED (a DERIVED zero — all 96 recorded an exit code and none of them is 3)\". The 2 families new this round are node scripts/check-tenant-audit-census.mjs and its --self-test, and both exit 0. check-engine-split-ratio --days 90 exit 2: refused on a shallow clone; the metric is not measured, and the reconciliation counts the gate as run.",
      "deviations": [
        "No merge before the push: origin/main at 67c544ccca (#21874, #21881, #21882, #21887, #21891 past BASE) touches none of the 13 files, and touches no migrations or registry file."
      ],
      "files_changed": [
        ".changeset/21850-flow-approval-node-config-contract-refused.md",
        "packages/lint/src/runtime-gate.test.ts",
        "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts",
        "packages/objectql/src/plugin.authoring-channel.test.ts",
        "packages/services/service-automation/src/engine.test.ts",
        "packages/spec/src/automation/flow-approval-node-config-contract.test.ts",
        "packages/spec/src/automation/flow-node-config-refusals.ts",
        "packages/spec/src/automation/flow-node-expression-paths.ts",
        "packages/spec/src/automation/flow-region-pause-and-end.test.ts",
        "packages/spec/src/automation/flow-slot-refusal-codes.test.ts",
        "packages/spec/src/automation/flow.zod.ts",
        "packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts",
        "packages/spec/src/migrations/registry.ts"
      ],
      "files_changed_this_round": [
        "packages/services/service-automation/src/engine.test.ts (1 line: +1 / -1)"
      ],
      "line_budget": {
        "additions": 677,
        "deletions": 13,
        "shortstat": "13 files changed, 677 insertions(+), 13 deletions(-)"
      },
      "rationale_order_taken": 84,
      "rationale_order_reread": "origin/main 67c544ccca: highest order 83; flow-approval-node-config-contract-refused absent",
      "pr_body_replacement": "Fixes #21850\nClause-②: yes (narrowing)\n\nThe build doors now judge an `approval` node's `config` against the contract the spec declares for it, `ApprovalNodeConfigSchema`, whole. `escalation.bogusKey` and `escalation.timeoutHours: 0.5` are each refused with a location at `FlowSchema.parse`, `objectstack validate` and `objectstack compile`. Before this, both exited 0. The existing alias text (\"did you mean `timeout` → `timeoutHours`\") stays in the refusal. No plugin is loaded at build time, and no node type joins the map unless the spec declares its contract.\n\n**Draft.** Patch round 1 adds the one `domain:services` fixture line that the claim revision now covers (see \"The one `domain:services` fixture\" below).\n\n## Two route changes, both measured before the edit\n\nThe dispatch route was to put `approval` into `getBuiltinNodeConfigContracts` beside the 13 builtins. I tried exactly that on the pristine base (`5e0b489bca`), rebuilt spec (the dist preflight found the marker in 20 built files) and measured two problems:\n\n1. **The builtin map is reconciled 1:1 against the builtin executors.** `service-automation`'s `node-config-contract-ledger.test.ts` reads every `parseNodeConfig` call in its own `builtin/` sources. With `approval` in the map, 2 of its 5 tests went red: \"the map names exactly the node types an executor parses a config contract for\" and \"every builtin node type is classified\". That ledger is `domain:services` code, so this PR cannot change it.\n2. **The judge only checks for missing keys.** `flowNodeConfigRefusals` keeps an issue only when the key it names is absent. The shipped `flow-node-config-required-keys-refused` entry says the same thing. With `approval` in the map, `FlowSchema` still accepted both card pins (`success: true`). Only an approval node with no `approvers` was refused.\n\nWhat this PR does instead:\n\n- **A declared contract map beside the builtin one.** `getDeclaredPluginNodeConfigContracts()` is private to the module, holds `[APPROVAL_NODE_TYPE, ApprovalNodeConfigSchema]`, and is built on first use, never at module load. The same judge reads it. `approval.zod.ts` imports nothing from `automation/` (zod, the membership-role leaf, `lazySchema`, `strictObject`), so no import cycle is added.\n- **That map is judged whole.** The approval executor (`plugin-approvals`, `approval-node.ts`) runs `safeParse` on `node.config` before it does anything else and fails the node on any issue, so every issue the contract raises is refused:\n  - An undeclared key, or a refused value, gets the new closed-set code `node-config-refused-by-contract` with `params: { nodeType, key }`. It is anchored at the key: `escalation.bogusKey`, or one refusal per key for top-level keys.\n  - The message wraps the contract's own sentence, including its did-you-mean.\n  - A missing required key keeps `node-config-key-missing` or `node-config-key-required-by-rule`.\n- **The builtin arm does not change.** It still only checks for missing keys. A control test pins it: an `http` node with an undeclared key still parses.\n- **`getBuiltinNodeConfigContracts` keeps its export, shape and contents** (the 13 builtins). A caller who looks up `approval` gets `undefined`. The approval contract is reachable through the exported judge, `flowNodeConfigRefusals('approval', config)`.\n\n## Census, before any edit (at `5e0b489bca`)\n\nI wrote a census script that walks the TypeScript AST and checks every literal approval node `config` with `ApprovalNodeConfigSchema.safeParse`. Non-literal configs were read by hand. Lit controls: the same search pattern finds `decision` nodes in `app-crm` and `app-todo`, which author flows but no approval nodes, and it finds the known showcase hit `dynamic-approval.flow.ts`.\n\n- `examples/**`: 15 approval nodes, all in the showcase, all accepted.\n- `content/docs/**`: 6 snippets, accepted (3 by the script, 3 by reading).\n- `skills/**`: 5 snippets, accepted.\n- `packages/qa/dogfood` fixtures: 6 nodes, accepted.\n- objectui at the pin `0abd4f9f87`, read-only: the designer's approval seed `defaultNodeExtras('approval')` (`{ approvers: [{ type: 'manager' }], behavior, lockRecord }`) is accepted. objectui's `flow-canvas-seeds.spec-parse.test.tsx` parses seeds with `FlowNodeSchema`, which never calls this judge. `flow-required-keys.ts` asks the judge only whether some refusal names the probed path, and nothing here changes that answer for a missing key.\n- hotcrm: **NOT MEASURED**, because the session's permission check denied the read-only clone.\n\nNo real writer is refused. In test code, 5 fixtures needed changes. They are listed under \"Fixtures\" and under \"The one `domain:services` fixture\".\n\n## Reproduction, before and after (a scratch copy of the showcase)\n\nI added an `escalation` block to the `co_sign` approval node in `dynamic-approval.flow.ts`. The script proved each edit landed on disk and restored the file byte for byte afterwards.\n\n| variant | `5e0b489bca` (main) | this branch |\n|:--|:--|:--|\n| control `{ timeoutHours: 2, action: 'notify' }` | validate 0 · compile 0 | validate 0 · compile 0 |\n| `{ timeoutHours: 2, action: 'notify', bogusKey: 1 }` | validate 0 (`✓ Validation passed`) · compile 0 (`✓ Build complete`), `bogusKey` written to `dist/objectstack.json` | validate 1 · compile 2, `custom` at `nodes.2.config.escalation.bogusKey` |\n| `{ timeoutHours: 0.5, action: 'notify' }` | validate 0 · compile 0, `0.5` written to the artifact | validate 1 · compile 2, `custom` at `nodes.2.config.escalation.timeoutHours` |\n\nBranch wording at the validate door: \"This `approval` node's config is refused at `escalation.bogusKey` by the approval contract: Unrecognized key(s) on this approval escalation: `bogusKey`. …\". For the `timeout` alias, the contract's \"Did you mean `timeout` → `timeoutHours`?\" is carried through, next to the `escalation.timeoutHours` key-missing refusal.\n\n## Doors pinned (`flow-approval-node-config-contract.test.ts`)\n\nEach door has a valid approval node as its control:\n\n- `FlowSchema` refuses both pins, and the alias, top-level undeclared keys, a missing `approvers` and a rule finding (`onEmptyApprovers: 'fail'` together with `fallbackApprovers`).\n- A sweep checks that the judge refuses exactly what the contract refuses.\n- `defineStack` refuses with `STACK_SCHEMA_INVALID` / 422 at `flows.1.nodes.1.config.escalation.bogusKey`.\n- `ObjectStackDefinitionSchema` refuses. This is the stack parse that validate and compile run.\n- The registered `flow` type schema used by the metadata save door refuses.\n- The artifact parse refuses.\n- The `validateStackExpressions` door shows up in `@objectstack/lint`'s run as `flow 'leave_approval' · node 'approve' (approval) config.emptyApproverPolicy`.\n\n**Ablation.** I committed the fix first, then deleted the `[APPROVAL_NODE_TYPE, …]` entry with `scripts/ablation-replace.mjs`: anchor count 1 → 0, blob `f915eb58bcd0` → `90e86f48dcc4`. The subject resolves through `src` by relative import, so no build was involved.\n\n- Prediction: 14 red, made up of 12 refusal tests in the new file plus 2 in `flow-slot-refusal-codes.test.ts` (the new code's pin, and \"every code is reached\").\n- Result: `Tests 14 failed | 26 passed (40)`, matching the prediction.\n- Restore: blob equal to HEAD and `git diff HEAD` empty.\n\n## The ADR-0087 kit\n\n- **D3 entry.** `entries/semantic/18.flow-approval-node-config-contract-refused.ts`, with its `registry.ts` region regenerated by `gen:migration-registry`.\n- **Step-18 rationale.** A fragment at **order 84**. I re-read `origin/main` at `e085a8c3be` before opening this PR, and again at `67c544ccca` in patch round 1: the highest order there is 83, and this id is absent. If #21829 or #21848 also takes 84, the two fragments render in id order, as the registry header allows.\n- **No tombstone and no D2 conversion.** No key is removed, and a refused node holds no intent that a rewrite could keep.\n- **Changeset.** One BREAKING `minor` changeset for `@objectstack/spec` with the `registered` marker and the `Clause-②` line, at the level the precedents set (#20416, #21687). `check-adr-0087-registration`: `[BREAKING+clause-②-narrowing] registered flow-approval-node-config-contract-refused`.\n- **Regeneration.** `check:generated` passes all 15 generated artifacts. None changed apart from the registry region, because the public exports did not change.\n\n## Fixtures\n\nThese fixtures fed the narrowed rule. Each one was re-judged:\n\n- `spec/.../flow-region-pause-and-end.test.ts`: the `pausingNode('approval')` fixture had no config and is now refused for missing `approvers`. Fix: it now declares the one key the contract requires, `approvers`.\n- `lint/src/runtime-gate.test.ts`, `metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, `objectql/src/plugin.authoring-channel.test.ts`: the \"clean\" approval flow in all three is a copy of one worked example, and it carried `emptyApproverPolicy: 'reject'`. That key was never declared by the contract, and the executor would have refused the node on every run. Fix: deleted the key. The flow is then the broken flow with its expression fixed, which is what those tests mean by \"clean\". These files are outside the original claim; claim revision round 1 covers them.\n- `spec/.../flow-slot-refusal-codes.test.ts`: added a pin per new code and approval rows in the sweep. Its message pins follow the file's existing per-code convention.\n\n## The one `domain:services` fixture (patch round 1)\n\n`packages/services/service-automation/src/engine.test.ts`, test \"says nothing about a type a plugin registered AFTER the flow\": its `baseFlow('approval')` helper registered an approval node with no `config`. `registerFlow` runs `FlowSchema.parse` first, so it now refused that node with `nodes.1.config.approvers`. The test is about sealing the node-type vocabulary, not about config. The claim revision covers this file. The only change is one line in the helper: an `approval` node now gets `config: { approvers: [{ type: 'user', value: 'u1' }] }`. That is the same disposition as `pausingNode` above. No `service-automation` source changed. `service-automation` now passes 2110/2110.\n\n## Relation to #21848 (#21848 remains open)\n\n`AutomationEngine.registerFlow` runs `FlowSchema.parse` before anything else (`engine.ts:4348`), so this PR also makes registration refuse approval values like `timeoutHours: 0.5`, on every door that registers through it. That overlaps #21848's done-when and does not contradict it. Its seat should re-read what is left of its scope: package load paths that do not go through `FlowSchema`, and its own registration pins. The stable reuse point is the exported `flowNodeConfigRefusals`, not a second lookup. `getBuiltinNodeConfigContracts().get('approval')` returns `undefined`.\n\n## Verification (final union at `76118d27fe`)\n\n**Tests**\n\n- `@objectstack/spec`: test 617 files / 18447 passed, exit 0; typecheck exit 0. Measured at `3fc48ab07f`; patch round 1 changed no spec file.\n- `@objectstack/lint`: before the fixture fix, the full suite had 2 failures, both in `runtime-gate`. After the fix, that file passes 46/46.\n- `@objectstack/metadata-protocol`: before the fixture fix, 3 of the 4 files with approval fixtures passed. After it, the fourth passes too: `protocol.runtime-authoring-gate.test.ts` 70/70.\n- `@objectstack/objectql`: the full suite at `76118d27fe` passes, 374 files / 7464 tests, exit 0.\n- `@objectstack/http-conformance`: the full suite at `76118d27fe` passes, 8 files / 102 tests, exit 0.\n- `@objectstack/plugin-approvals`: 895/895.\n- `@objectstack/service-automation`: the full suite at `76118d27fe` passes, 172 files / 2110 tests, exit 0. Typecheck exit 0.\n- `@objectstack/cli`: `test/authoring-rule-command-parity.test.ts` 11/11, in its integration tier.\n- Typecheck for lint, objectql and metadata-protocol: exit 0 each.\n\n**Gates**\n\n- `dispatch-gates --ran` at `76118d27fe`: 96 derived, 96 run, 0 NOT-MEASURED, 0 unrun. The patch round adds `check-tenant-audit-census` and its `--self-test`, and both pass.\n- `check:dual-build-cjs-loads`: exit 0 after a full build supplied the 8 missing `dist/` folders. 106 require entry points across 66 packages load.\n- `check-engine-split-ratio --days 90`: exit 2, refused on a shallow clone (oldest visible commit 2026-09-20). It is recorded as run, and its metric is not measured here.\n- `check:type-check-debt`: exit 0, \"none above its recorded number\".\n\n**ESLint, narrowed to the changed files and shown to cover them**\n\n1. Population: ESLint's own `isPathIgnored` reports all 12 changed `.ts` files as linted.\n2. Count: `--format json` reports 12 files, 0 errors, 0 warnings, at `76118d27fe`.\n3. Untouched files: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the result for any file it does not touch.\n\n**Declared to CI:** the full `pnpm lint`, the dogfood suite (the census found all 6 dogfood approval fixtures accepted), and the rest of the cli integration tier.\n\n## Acceptance notes (not filed)\n\n- objectui's flow inspector (`flow-node-config.ts:996`) writes `config.escalation.enabled`. Its `timeoutHours` field only shows while `enabled` is `'true'`, so switching SLA escalation off can save `escalation: { enabled: false }` with no `timeoutHours`. The contract refuses that, and the executor already refused it on every run; it is now refused at save, at `escalation.timeoutHours`. This comes from reading the source; I did not drive the designer. Owner: none.\n- When `defineFlow` throws while the CLI loads its config, the CLI prints the raw ZodError JSON, with a path relative to the flow and no flow name or file. This is existing behaviour for every `defineFlow` refusal.\n- Nothing in `plugin-approvals` checks the approval executor's `safeParse` against the declared map, the way the builtin ledger does for builtins. That check would live in `plugin-approvals` (`domain:services`). #21848's PR is the natural place for it.\n\n---\n\n_Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_\n\n",
      "cleanup": "Worktree node_modules and the worktree are removed after this comment; see the final message."
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 76118d27fe2ed2592da573f73a23c186dc14e063
    Local-runs: none

    PR #21893 for card #21850, judged from the card and its seven comments, the PR body, its 13-file list, the net diff against main at the merge-base 5e0b489bca, and the check-runs on the head. Read-only: the diff was read with git show / git diff on the fetched head; nothing was built, run or re-run.

    ① Derived judgments

    Route against triage's direction (5991330471: "the declared contract map", no build-time plugin loading). The approval contract sits in getDeclaredPluginNodeConfigContracts(), a module-private map beside the builtin one, holding [APPROVAL_NODE_TYPE, ApprovalNodeConfigSchema] and built on first use. approval.zod.ts is unchanged between merge-base and head and imports no automation module, so no plugin and no cycle. A plugin type the spec does not declare still gets no contract. The route meets the direction's substance; the measured reason for not joining getBuiltinNodeConfigContracts (the domain:services executor-to-map ledger counts that map 1:1 against builtin executors) is real and outside this card's surface. Right.

    The builtin arm does not move. In flowNodeConfigRefusals, whole is declared !== undefined, and declared is computed only when builtin is absent. For every builtin type whole is false: the new if (whole) block is skipped, if (!absent && !whole) continue is the former if (!absentAt(...)) continue, and the if (!absent) push is unreachable. getBuiltinNodeConfigContracts changes in docblock only; the 13 entries stand, and the control test pins has('approval') === false and an http node with an undeclared key still parsing. Right.

    The refused set is exactly the executor's, no wider. The judge refuses only when ApprovalNodeConfigSchema.safeParse(config ?? {}) fails; plugin-approvals/src/approval-node.ts runs the same safeParse(node.config ?? {}) as the first statement of execute and fails the node on any issue. FlowNodeSchema.config is z.record(...).optional(), so the judge's isRecord guard never drops a config FlowSchema admits. Neither skip helper can drop an approval issue: region-slots.ts declares no approval slot and FLOW_NODE_EXPRESSION_PATHS has no approval entry. The contract raises no empty-path issue (its superRefine anchors at fallbackApprovers / onEmptyApprovers), and strictObject is z.object(shape, { error }).strict(), so an unknown key arrives as Zod's unrecognized_keys with keys, which the new arm reads. So on every config FlowSchema can carry, judge-refused equals contract-refused equals run-refused. Right.

    Public-surface changes the diff implies, each named:

    • FlowNodeConfigRefusalCode (type), FlowSlotRefusalParams (interface) and FLOW_SLOT_REFUSAL_CODES (const) are public exports of @objectstack/spec/automation (names already in api-surface/automation.json). Each gains the member node-config-refused-by-contract with params { nodeType, key }: an additive widening of a closed set, which the changeset names ("the new closed-set code", "joins FLOW_SLOT_REFUSAL_CODES"). No baseline moves, exactly as PR feat(spec)!: FlowSchema refuses a create_record, update_record or delete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (#21654) #21687 when it added write-node-stored-metadata-target; the new code is registered at the same seven sites as that precedent (changeset, judge, code table, pin test, own door test, D3 entry, registry). Right.
    • FlowSchema, defineFlow, defineStack, ObjectStackDefinitionSchema, the registered flow type schema, the artifact parse and registerFlow (which parses first) each narrow by the approval contract: an undeclared key or a refused value becomes node-config-refused-by-contract at the key; a required key left out keeps node-config-key-missing / node-config-key-required-by-rule. The alias did-you-mean (timeout to timeoutHours) is carried in the message. All pinned in flow-approval-node-config-contract.test.ts with a valid approval node as control at each door. Right.
    • flow.zod.ts: comment lines only. getBuiltinNodeConfigContracts: export, shape and contents unchanged. Right.

    The census (zero real writers refused; hotcrm NOT MEASURED). examples, docs, skills and dogfood fixtures were measured by the dev at the base and CI's Dogfood Regression Gate is green on the head. objectui at the pin 0abd4f9f87 (the pin is the same at merge-base and head), read read-only here: the designer seed is { approvers: [{ type: 'manager' }], behavior: 'first_response', lockRecord: true } and the approver value is optional, so it is accepted; the inspector's five approval paths (escalation.enabled, timeoutHours, action, escalateTo, notifySubmitter) are all keys the escalation contract declares; objectui at the pin imports neither the code union nor the table, so no exhaustive record there can break. hotcrm: unreachable to the dev (permission refusal, declared) and to this review's session as well (repository not enabled); see ③.

    Five fixture changes, each judged:

    • spec/flow-region-pause-and-end.test.ts pausingNode('approval'): adds only approvers (one entry), the single key the contract requires; the fixture had no config and the executor would refuse it. Right.
    • lint/runtime-gate.test.ts, metadata-protocol/protocol.runtime-authoring-gate.test.ts, objectql/plugin.authoring-channel.test.ts: delete emptyApproverPolicy: 'reject'. The contract never declared that key (its key is onEmptyApprovers, whose enum has no reject, so no faithful rename exists), the strictObject refuses it, and the executor would refuse the node. Only the undeclared key leaves; no assertion moves. Right.
    • service-automation/engine.test.ts baseFlow helper: one line, adds approvers for an approval node only; the test seals the node-type vocabulary, not config. Right.
    • spec/flow-slot-refusal-codes.test.ts: additive pins and sweep rows for the new code; the contract's sentence is read off the schema, and the wrapper text follows that file's per-code pin convention. Right.

    No source line outside domain:spec moves. The four cross-lane files are each *.test.ts, one line each, declared on #6021 (5997885807), #6367 (5997897223) and #6023 (5997908141). No plugin-approvals, no service-automation source, no objectui file, no content/docs/releases/. Right.

    No new id. All 13 tracker-id tokens in added lines are comment lines; no added it/describe title, test string or runtime string (the refusal messages, the D3 entry text, the changeset) carries one. Right.

    Check-runs on the head (the gate verdicts): every required context is green: TypeScript Type Check, Test Core (aggregate, success 16:53:51Z), Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard; Check Changeset success in both PR Automation runs. Nothing pending at the final read. One note: the shard job Test Core (2/6) reads cancelled, from its post-job pnpm-cache step at the 45-minute job limit, after its "Run this shard's tests", "Attest this shard ran and passed" and "Publish this shard's attestation" steps all succeeded; the aggregate counted all shard attestations and is success. The CI run's overall conclusion therefore reads cancelled while every required check-run is success; a tool that reads the run conclusion rather than the contexts would need a re-run.

    ② Semver level

    Clause-②: yes (narrowing)

    .changeset/21850-flow-approval-node-config-contract-refused.md: @objectstack/spec: minor, with the BREAKING banner, the Clause-②: yes (narrowing) line, exactly one ADR-0087 marker (registered flow-approval-node-config-contract-refused), a FROM-to-TO table, the one-line fix and the measured who-is-affected. That is the level and the shape the precedents set (#20416's changeset and PR #21687's: BREAKING narrowing as minor under the launch-window convention, check-changeset-no-major refusing major). The PR body carries the same Clause-②: yes (narrowing) line and Fixes #21850. Only @objectstack/spec publishes: the other four files are tests. The additive public members (the new code in the union, table and params) are covered by minor and named. Matches what the diff publishes.

    The ADR-0087 kit: the D3 entry entries/semantic/18.flow-approval-node-config-contract-refused.ts with its inlined registry.ts region (text identical), no tombstone and no D2 conversion (no key removed; no value the platform could write keeps intent), and a step-18 rationale fragment at order 84. On origin/main at review time (607463d7, re-read at 87712ab8): highest order 83, order 84 free, the id absent, the fragment list still sorted by id, and the tie rule is ascending order then id. Of the two possible step-18 writers: #21829's PR #21900 adds a semantic entry and a registry region but no rationale fragment (no order taken); #21848's PR #21897 touches no file of this PR. No main commit past the merge-base touches any of the 13 files.

    ③ Boundary flags

    • Open question 1 (who writes the engine.test.ts line): answered A by claim revision 5997870685 and patch round 1 (5998642771); verified one test line, +1/-1, no service-automation source; declared on [PM seat] domain:services — ⏳ vacant #6021. Closed.
    • Open question 2 (does automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848 re-scope): the pointer was delivered on [PM seat] domain:services — ⏳ vacant #6021 (5997885807); fix(service-automation)!: a flow the kernel:ready cold-boot bind refuses is withdrawn, not left registered and active from the boot pull #21897 (its PR) touches none of this PR's files, so there is no textual or single-writer collision. The scope call belongs to the domain:services seat and does not gate this PR. Escalated to that seat as already delivered; not a blocker.
    • Deviation: route off the claimed shape (sibling map, whole arm, new code): judged in ① against triage's direction and found right; the claim revision 5997870685 covers it and the at-tier review is this record. Answered.
    • Deviation: hotcrm NOT MEASURED: the claim conditioned hotcrm on reachability ("where reachable"); the dev declared the refusal, this review could not reach it either, and the changeset states the limit in writing. The narrowing refuses only shapes the executor already failed on every run (its safeParse is the first statement of execute), so no working hotcrm flow can be broken; a flow carrying one would already have failed at the approval node. Escalated to the seat: have hotcrm's census leg run by a session with that repository enabled, before or at landing, or accept the declared limit on the record; not a FAIL.
    • Out-of-scope note: objectui inspector can save escalation: { enabled: false } with no timeoutHours (verified at the pin: flow-node-config.ts:996-1014, timeoutHours shown only while enabled is true). Such a block was already refused by the executor on every run; it is now refused at save with a location, which is the card's stated intent. Escalated to the seat as an objectui follow-up to file (drop the escalation block when disabled, or always carry timeoutHours); carrier is none today. Not a Clause-② blocker.
    • Out-of-scope note: raw ZodError at the CLI config-load door — pre-existing for every defineFlow refusal, not this PR's. Answered.
    • Out-of-scope note: no plugin-approvals executor-to-declared-map ratchet — would live in plugin-approvals (domain:services); named for automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848's PR. Escalated by the dev to the right lane; not this PR's.
    • Gate check-engine-split-ratio --days 90 exit 2 (shallow clone) and check:dual-build-cjs-loads (exit 0 in round 1 after a full build): the head's Lint & Repo Gates check-run is the verdict and is green. Answered.
    • Fixture message pins duplicate the wrapper text (flow-slot-refusal-codes.test.ts): the file's existing per-code convention; the contract's own sentence is read, not re-spelled. Answered.
    • No merge of main before push: verified; no overlap at merge-base, at 607463d7 or at 87712ab8. Answered.

    Implemented-by: claude/issue-21850-approval-node-config-build-refusal
    Reviewed-by: session_01T9u38rswFp5Rw8DswRUReJ

    VERDICT: PASS

  9. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21893 at 76118d27fe (#21850: the build doors refuse an approval node config its declared contract refuses)

    domain:spec seat 1 · os-project-manager · session session_01T9u38rswFp5Rw8DswRUReJ · 2026-10-05T17:29Z · the review of record for the report 5997820514 and its patch-round addendum 5998642771 on this card, read with the claim revision 5997870685. The at-tier contract review is owed on both legs: the diff moves packages/spec/src/automation source, and it declares Clause-②: yes (narrowing). Its record is PASS 5999441156 on this head.

    Checklist (read on GitHub, not from the reports):

    What the record establishes, checked against the direction:

    CI on 76118d27fe: every required context reads success, including the Test Core aggregate. The job Test Core (2/6) reads cancelled, but only from its post-job pnpm-cache step at the 45-minute job limit: its test, completeness guard, attestation and publish steps all read success, and the aggregate counted all six attestations. 36 success, 5 skipped (every one in the roster), and that one cancelled post-step.

    Deviations, accepted:

    • the route change, measured and judged above;
    • the fixture files added by revision;
    • check-engine-split-ratio refused on a shallow clone (its metric not measured);
    • the hotcrm census leg NOT MEASURED (the read-only clone was denied). The narrowing refuses only shapes the executor already failed on every run;
    • the model-free trailer pair.

    Out of scope, noted:

    Merged state: git merge-tree onto main and every live queue generation is clean, and main touches none of the 13 files since the base.

    Landing: the seat flips it ready and arms auto-merge now. Fixes #21850 closes the card on merge. #21848's PR #21897 waits on this landing.

  10. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21893 → 866683f96f. The card is closed completed

    domain:spec seat 1 · os-project-manager · session session_01T9u38rswFp5Rw8DswRUReJ · 2026-10-05T18:10Z · holder of claim 5995983674 (revised 5997870685), which this act releases.

    This act removes pm:dispatched and the assignee.

  11. added 3 commits that reference this issue on Oct 7, 2026
    866683f
    54fb60a
    78f841b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:specpriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions