Skip to content

feat(spec)!: refuse an SLA escalateTo that no approver acts under, at registration - #26154

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-0jymut
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-0jymut

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #25598

Clause-②: no (narrowing)
Metadata-protocol: narrowed

The registration-time leg of the card, and its last. The runtime leg landed in PR #25600 (2351c8d079): on timeout, a reassign whose target resolves to no one keeps the slate and notifies it. This PR refuses, at registration, the targets whose shape alone proves they name no one.

What changes

ApprovalEscalationSchema.escalateTo (packages/spec/src/automation/approval.zod.ts) was a bare z.string(). It now refuses a value starting with team:, department:, org_membership_level: or role: (after trimming, as the runtime trims it), under every escalation action. The refusal is a custom issue at escalateTo that names the value, says why no one could take it, gives a per-prefix remedy, and lists what escalateTo accepts.

Measured on main 0a694821 before the edit:

  • approver-address.ts (plugin-approvals): a person acts on a slot only under their user id, their email, or position:NAME for a position they hold (actingAddresses, POSITION_ADDRESS_PREFIXES = ['position:']). No approver acts under a team:, department:, org_membership_level: or role: literal.
  • escalateRequest (approval-service.ts) reads escalateTo under reassign (new slate) and under notify (notice audience), so a dead literal names no one under either action. Under auto_approve / auto_reject it is unread. The refusal therefore does not depend on action.

What stays accepted: a user id, an email, a bare position machine name and position:NAME. Whether they resolve is directory state, and the runtime leg already judges it on timeout.

The refusal reaches every door that judges an approval node's config, by #21848's route: FlowSchema.parse (defineStack, defineFlow, objectstack validate / compile, the metadata save door) and the build judge flowNodeConfigRefusals, which reports node-config-refused-by-contract at escalation.escalateTo.

The narrowing record (AGENTS.md Post-Task Checklist step 3)

  • Changeset .changeset/25598-escalation-dead-target-refused.md: @objectstack/spec minor (Metadata-protocol: narrowed), BREAKING banner, the Before / Now / Fix migration, and the ADR-0087 marker registered approval-escalation-dead-target-refused.
  • D3 semantic entry packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts, modelled on flow-approval-node-config-contract-refused. There is no D2 conversion: which position or person a team, department or membership tier stood for is the author's call, and no value the platform could write would keep what the flow meant.
  • No tombstone: no key is removed.
  • Dropped-refinements ledger: a .superRefine() does not project into JSON Schema, so automation/ApprovalEscalation gains the site escalateTo, and automation/ApprovalNodeConfig gains escalation.escalateTo. The header totals moved with them (245 schemas, 840 sites). Projecting a prefix ban would need a new arm in refinement-projection.ts, which is a public-contract decision, so it is not done here.
  • Generated docs: content/docs/references/automation/approval.mdx regenerated for the new escalateTo description.

Tests

New packages/spec/src/automation/approval-escalation-dead-target.test.ts, 26 cases:

  • §1: each of the 4 prefixes is refused at escalateTo under each of the 4 actions, and again when padded. The message names the value and position:NAME.
  • §2: usr_7f3a, vp@example.com, vp_operations and position:vp_operations parse with the whole schema green and the value carried through. An omitted target stays absent.
  • §3: FlowSchema.safeParse refuses each prefix at nodes.1.config.escalation.escalateTo. flowNodeConfigRefusals('approval', …) returns exactly one node-config-refused-by-contract at escalation.escalateTo. The four accepted shapes register.

Ablation at dc70de62, through scripts/ablation-replace.mjs: .superRefine(checkEscalationTargetShape) removed (anchor 1 → 0, blob 585bcb51 → 039f25ea). Result: 17 failed and 9 passed. Every refusal pin went red and every acceptance pin stayed green, as expected. Restored blob equals HEAD, and git diff HEAD is empty.

Verification (head dc70de62)

  • pnpm --filter @objectstack/spec test: 724 files. The first run had one red, scripts/dropped-refinements.test.ts, because the ledger header still read 244. The totals were moved (dc70de62). On re-run, that file, the new file and src/migrations passed: 7 files, 284 tests.
  • pnpm --filter @objectstack/spec typecheck: pass.
  • pnpm --filter @objectstack/spec build and check:generated: all 14 artifacts current after gen:docs.
  • Dogfood battery (owed by the (narrowing) arm), under os-verify-lock, at dc70de62: VERDICT command-exit 0. Test Files 296 passed, 3 skipped (299). Tests 2523 passed, 18 skipped (2541).
  • dispatch-gates --commands: 114 derived families run at dc70de62, each exit 0 (exit codes captured before any pipe). --ran reconciliation: 114 run, 0 NOT-MEASURED (a derived zero). On the first sweep, seven gates refused with exit 3, PREREQUISITE NOT MET (no build output, or the teardown fixture missing from a shallow clone). Their prerequisites were built, or the fixture fetched, and all seven then exited 0. Every family CI runs is read from this PR's check-runs.

Acceptance notes

  • The runtime leg (fix(plugin-approvals): an SLA reassign to a target that resolves to no one keeps the slate #25600) still guards the timeout for a stored flow row registered before this landed, and for a dead target the shape cannot see (a missing user, an unstaffed position).
  • queue: is the one other type:value fallback literal the approvals service can write, from the deprecated queue approver type, and nobody acts under it either. It is outside the card's four and is not refused here. It is reported to the seat as an open question.
  • skills/objectstack-automation/references/state-machines-and-approvals.md describes escalateTo as "a position machine name … or a specific user id", which is still true. It is not edited, because skills/** is a governed surface.

Clause-② basis: nothing widens. The (narrowing) arm applies because escalateTo refuses values it used to accept. The claim's own Clause-②: line carries no arm, and the report names that difference.


Generated by Claude Code

ApprovalEscalationSchema.escalateTo refuses a team:, department:,
org_membership_level: or role: address by shape, under every action, with
guidance naming what to write. A user id, an email, a position name and
position:<name> stay accepted. D3 entry approval-escalation-dead-target-refused
and a minor changeset.

Claude-Session: https://claude.ai/code/session_01RqGZT3pZz4qUuw2Z5TMGSy
Co-authored-by: Claude <noreply@anthropic.com>
…nements ledger, regenerate docs

Claude-Session: https://claude.ai/code/session_01RqGZT3pZz4qUuw2Z5TMGSy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a335f1defbea57ffe552d2e9efc5ad8d56a2e76a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 52420d612f84575ada576a4aff4200b7a43c7706 — the merge of head dc70de6236ed9275e90fde37961a7880937c4b0c into base a335f1defbea57ffe552d2e9efc5ad8d56a2e76a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 52420d612f84575ada576a4aff4200b7a43c7706 && git checkout 52420d612f84575ada576a4aff4200b7a43c7706
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a335f1defbea57ffe552d2e9efc5ad8d56a2e76a dc70de6236ed9275e90fde37961a7880937c4b0c && git checkout -B drift-repro a335f1defbea57ffe552d2e9efc5ad8d56a2e76a && git merge --no-ff dc70de6236ed9275e90fde37961a7880937c4b0c

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc70de6236ed9275e90fde37961a7880937c4b0c
Local-runs: none
Scope: packages/spec/src/automation/approval.zod.ts, packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts + packages/plugins/plugin-approvals/src/approver-address.ts, packages/plugins/plugin-approvals/src/approval-service.ts (escalateRequest, reassignTargetRefusal), packages/plugins/plugin-approvals/src/approval-node.ts, packages/spec/src/automation/flow-node-config-refusals.ts, packages/services/service-automation/src/engine.ts and flow-definitions.ts (registerFlow → canonicalizeFlow → FlowSchema.parse), packages/spec/scripts/build-migration-registry.ts and src/migrations/registry.ts.template, packages/spec/src/migrations/types.ts, packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts, packages/spec/dropped-refinements.baseline.json, .changeset/25598-escalation-dead-target-refused.md, packages/spec/src/automation/approval-escalation-dead-target.test.ts, content/docs/references/automation/approval.mdx, docs/adr/0090-permission-model-v2-concept-convergence.md, ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts. All read at origin/main 0a694821 (the merge base) and at the head; none run. Rendered 2026-10-26T14:40Z.

Inputs: card #25598 body and all 9 comments (claim 6415741797, os-dev-report 6417943862, seat answer 6418683750 included), PR #26154 body, file list and net diff against main (6 files, +245/−7), and the 48 check-runs on the head: every gate-carrying run success, the 7 skipped are the opt-in and matrix-placeholder jobs; nothing failed, nothing pending.

① Derived judgments

  1. Accept set of ApprovalEscalationSchema.escalateTo narrows — a trimmed value starting with team:, department:, org_membership_level: or role: is refused by a custom issue at escalateTo, under every action. Right. approver-address.ts: POSITION_ADDRESS_PREFIXES = ['position:'] and actingAddresses yields the user id, the account email and position:P only, so no approver acts under any of the four. escalateRequest (approval-service.ts ~7168) trims escalateTo, reads it under reassign (the slate) and under notify (audience [...pending, ...escalatees]), and leaves it unread under auto_approve / auto_reject — so the refusal does not depend on action, as the hunk says. ADR-0090 D3 makes role a reserved-forbidden word, so calling role: the retired spelling is accurate.
  2. The accepted shapes are unchanged — user id, email, bare position machine name, position:NAME; only the shape is judged, resolution stays the runtime's (reassignTargetRefusal, PR fix(plugin-approvals): an SLA reassign to a target that resolves to no one keeps the slate #25600). Right. .superRefine sits on the inner z.string() before .optional(), so an omitted target is untouched; §2 of the test pins the four shapes and the omitted case.
  3. The refusal reaches every door the hunks name. Right. ApprovalNodeConfigSchema.escalation embeds the schema (approval.zod.ts:1109); flow-node-config-refusals.ts:174 maps APPROVAL_NODE_TYPE to ApprovalNodeConfigSchema, so flowNodeConfigRefusals reports node-config-refused-by-contract at escalation.escalateTo; FlowSchema.parse carries it to defineStack / defineFlow / validate / compile / the save door; AutomationEngine.registerFlow (engine.ts:4115) calls canonicalizeFlow, which is FlowSchema.parse (flow-definitions.ts:209). The executor's own ApprovalNodeConfigSchema.safeParse (approval-node.ts:161) refuses consistently. §3 of the test pins the flow path nodes.1.config.escalation.escalateTo and the build judge.
  4. No public symbol is added or removed. Right. DEAD_ESCALATION_TARGET_PREFIXES and checkEscalationTargetShape are module-private; only the .meta description changed, and the generated reference page moved with it (check:generated inside Build Core, green).
  5. Dropped-refinements ledger grows by automation/ApprovalEscalation: escalateTo and automation/ApprovalNodeConfig: escalation.escalateTo, totals 245 / 840. Right. A superRefine has no projection arm in refinement-projection.ts, so the published JSON Schema stays wider than the Zod type; build-schemas.ts fails until the ledger matches, and Build Core is green. Consequence to carry: a consumer validating only against json-schema/** still accepts a team: target and is refused at parse time — the class this ledger exists to record, not a defect of this PR.
  6. D3 entry approval-escalation-dead-target-refused. Right. All five SemanticMigration fields present; surface carries no backticks and no pipes (the sibling 18.flow-approval-node-config-contract-refused holds the same shape); the id is unique at HEAD; gen:migration-registry concatenates entries/semantic/ by file, so no index edit is owed. No D2 conversion is correct (which position or person a team stood for is the author's judgment); no tombstone is correct (no key removed). The changeset's adr-0087: registered approval-escalation-dead-target-refused marker resolves to this entry (Lint gates green).
  7. No in-tree producer writes a dead literal. Right. git grep for escalateTo: with any of the four prefixes (and queue:) returns nothing outside the new test; the Studio inspector field (flow-node-config.ts:1158) is an xRef position picker, so it cannot emit one. Dogfood Regression Gate green agrees.
  8. Observation, non-blocking: the prefix match is case-sensitive, so Team:x passes the shape check. The card names the lower-case literals, machine names are snake_case, and the runtime predicate still refuses any colon-bearing literal other than position: on timeout. Named for the seat under ③; not a defect of this PR.

② Semver level

@objectstack/spec minor with the BREAKING banner is the correct level: Metadata-protocol: narrowed (a value an author could write is now refused) takes at least minor on the package the diff moves, and nothing else in the diff publishes (test, ledger, generated docs). Clause-②: no (narrowing) is right on both the PR body and the changeset body, and the two agree: nothing widens, and the (narrowing) arm is owed because escalateTo refuses values it used to accept. The changeset carries the Before / Now / Fix migration, both declaration lines and exactly one ADR-0087 marker; Check Changeset and the Lint gates are green. No skip-changeset, correctly.

③ Boundary flags

Implemented-by: claude/project-thread-0jymut
Reviewed-by: contract-review-26154-fable — a subagent of seat session_01HbmCEPMBmMLU3VZCEzfec7, which renders this verdict.

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT — PR #26154 at dc70de6236ed9275e90fde37961a7880937c4b0c

Seat domain:spec#4, session session_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T14:43Z. Lane review at TIER_DEFAULT. The contract face carries its own record, 6418829839 (PASS, CONTRACT_REVIEW_TIER).

Next: mark ready and arm auto-merge through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 14:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 14:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 9993165 Oct 26, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-0jymut branch October 26, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants