Skip to content

Commit 9993165

Browse files
feat(spec)!: refuse an SLA escalateTo that no approver acts under, at registration (#26154)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbCSEueUJDFmeZ9eARPJvzQM)_ 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 (#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](https://claude.ai/code/session_01RqGZT3pZz4qUuw2Z5TMGSy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7111e14 commit 9993165

6 files changed

Lines changed: 245 additions & 7 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an SLA escalation's `escalateTo` naming a `team:`, `department:`, `org_membership_level:` or `role:` address is refused at registration
6+
7+
Clause-②: no (narrowing) — `ApprovalEscalationSchema.escalateTo` refuses values it used to accept. Nothing is widened.
8+
9+
Metadata-protocol: narrowed (an approval node's `escalation.escalateTo` written as a `team:`, `department:`, `org_membership_level:` or `role:` address is refused)
10+
11+
<!-- adr-0087: registered approval-escalation-dead-target-refused -->
12+
13+
`escalateTo` was a bare string. The approvals service resolves it as a position machine name and falls back to the value as written, and a person acts on a pending slot only under their user id, their email or `position:<name>` for a position they hold. So a `team:`, `department:` or `org_membership_level:` literal, or the `role:` spelling ADR-0090 D3 retired, names nobody under any escalation action: a `reassign` on timeout would hand the request to no one (the runtime now keeps the slate and notifies it instead), and a `notify` reaches no one through it. The schema accepted it and every build door exited 0.
14+
15+
`ApprovalEscalationSchema` now refuses those four prefixes at `escalateTo`, under every action and with surrounding whitespace trimmed as the runtime trims it. The refusal names the value and the shapes that are accepted. It reaches every door that judges an approval node's config: `FlowSchema.parse` (`defineStack`, `defineFlow`, `objectstack validate` / `compile`, the metadata save door) and `AutomationEngine.registerFlow`. A user id, an email, a position machine name and `position:<name>` stay accepted; whether they resolve is directory state, judged by the runtime on timeout. The D3 entry `approval-escalation-dead-target-refused` carries the judgment for `os migrate meta`; there is no D2 conversion, because only the author knows which position or person was meant.
16+
17+
**BREAKING** for an author whose approval node escalates to a team, department, membership tier or `role:` address.
18+
19+
- **Before.** `escalation: { timeoutHours: 24, action: 'reassign', escalateTo: 'team:night_shift' }` parsed, and on timeout the request was handed to no one.
20+
- **Now.** The flow is refused at `nodes.N.config.escalation.escalateTo`, with a message naming the value and what to write instead.
21+
- **Fix.** Name a target someone acts under: `escalateTo: 'night_shift_lead'` (a position its people hold), `'position:night_shift_lead'`, or a person's user id or email. Write `role:<name>` as `position:<name>`.

‎content/docs/references/automation/approval.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const result = ApprovalDecision.parse(data);
4141
| **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all |
4242
| **timeoutHours** | `number` | ✅ | Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday |
4343
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
44-
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
44+
| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:`<name>`' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one |
4545
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
4646

4747

@@ -117,7 +117,7 @@ const result = ApprovalDecision.parse(data);
117117
| **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all |
118118
| **timeoutHours** | `number` | ✅ | Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday |
119119
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
120-
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
120+
| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:`<name>`' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one |
121121
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
122122

123123

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 244,
6-
"droppedRefinementSites": 838,
5+
"publishedSchemasWithDroppedRefinements": 245,
6+
"droppedRefinementSites": 840,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -528,9 +528,15 @@
528528
"data.nodes.element.in.waitEventConfig"
529529
]
530530
},
531+
"automation/ApprovalEscalation": {
532+
"sites": [
533+
"escalateTo"
534+
]
535+
},
531536
"automation/ApprovalNodeConfig": {
532537
"sites": [
533-
""
538+
"",
539+
"escalation.escalateTo"
534540
]
535541
},
536542
"automation/AssignmentConfig": {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25598] An SLA escalation's `escalateTo` that no approver could ever act
5+
* under is refused at registration — the contract half of the card whose
6+
* runtime half keeps a stranded slate from being written on timeout.
7+
*
8+
* A person acts on a slot under their user id, their email or
9+
* `position:<p>` (`approver-address.ts` in `plugin-approvals`), so a `team:`,
10+
* `department:` or `org_membership_level:` literal, or the retired `role:`
11+
* spelling, names nobody under any escalation action. The shape alone settles
12+
* it, so it is judged here; whether a user id or a position resolves is
13+
* directory state, so every other shape stays accepted.
14+
*
15+
* - §1 each refused prefix is refused at `escalateTo`, under every action,
16+
* padded or not;
17+
* - §2 each accepted shape parses, and an omitted target still parses;
18+
* - §3 the refusal reaches the door authors meet: `FlowSchema` and the build
19+
* judge `flowNodeConfigRefusals`, anchored at the key.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
24+
import { ApprovalEscalationSchema } from './approval.zod';
25+
import { flowNodeConfigRefusals } from './flow-node-config-refusals';
26+
import { FlowSchema } from './flow.zod';
27+
28+
const REFUSED = ['team:night_shift', 'department:sales', 'org_membership_level:admin', 'role:sales_manager'];
29+
const ACCEPTED = ['usr_7f3a', 'vp@example.com', 'vp_operations', 'position:vp_operations'];
30+
const ACTIONS = ['reassign', 'notify', 'auto_approve', 'auto_reject'] as const;
31+
32+
function issues(value: unknown): { code: string; path: string }[] {
33+
const result = ApprovalEscalationSchema.safeParse(value);
34+
if (result.success) return [];
35+
return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));
36+
}
37+
38+
function flowWith(escalation: Record<string, unknown>) {
39+
return {
40+
name: 'expense_approval',
41+
label: 'Expense approval',
42+
type: 'autolaunched',
43+
nodes: [
44+
{ id: 'start', type: 'start', label: 'Start' },
45+
{
46+
id: 'gate', type: 'approval', label: 'Finance',
47+
config: { approvers: [{ type: 'position', value: 'finance_manager' }], escalation },
48+
},
49+
{ id: 'approved', type: 'end', label: 'Approved' },
50+
{ id: 'rejected', type: 'end', label: 'Rejected' },
51+
],
52+
edges: [
53+
{ id: 'e1', source: 'start', target: 'gate' },
54+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
55+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
56+
],
57+
};
58+
}
59+
60+
describe('§1 a target no approver acts under is refused at escalateTo', () => {
61+
it.each(REFUSED)('%s is refused under every action', (escalateTo) => {
62+
for (const action of ACTIONS) {
63+
expect(issues({ timeoutHours: 24, action, escalateTo })).toEqual([{ code: 'custom', path: 'escalateTo' }]);
64+
}
65+
});
66+
67+
it.each(REFUSED)('%s is refused with surrounding whitespace, as the runtime trims it', (escalateTo) => {
68+
expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo: ` ${escalateTo} ` }))
69+
.toEqual([{ code: 'custom', path: 'escalateTo' }]);
70+
});
71+
72+
it('names the value it refused and the shapes it accepts', () => {
73+
const result = ApprovalEscalationSchema.safeParse({ timeoutHours: 24, action: 'reassign', escalateTo: 'team:ops' });
74+
expect(result.success).toBe(false);
75+
const message = result.success ? '' : result.error.issues[0]!.message;
76+
expect(message).toContain("'team:ops'");
77+
expect(message).toContain("'position:<name>'");
78+
});
79+
});
80+
81+
describe('§2 a user id, an email and a position stay accepted', () => {
82+
it.each(ACCEPTED)('%s parses, whole schema green', (escalateTo) => {
83+
expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo })).toEqual([]);
84+
const parsed = ApprovalEscalationSchema.parse({ timeoutHours: 24, action: 'reassign', escalateTo });
85+
expect(parsed.escalateTo).toBe(escalateTo);
86+
});
87+
88+
it('an omitted target still parses and stays absent', () => {
89+
expect(issues({ timeoutHours: 24 })).toEqual([]);
90+
expect(ApprovalEscalationSchema.parse({ timeoutHours: 24 }).escalateTo).toBeUndefined();
91+
});
92+
});
93+
94+
describe('§3 the refusal reaches the flow door', () => {
95+
it.each(REFUSED)('FlowSchema refuses %s at the node config key', (escalateTo) => {
96+
const result = FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo }));
97+
expect(result.success).toBe(false);
98+
const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.'));
99+
expect(paths).toContain('nodes.1.config.escalation.escalateTo');
100+
});
101+
102+
it.each(REFUSED)('the build judge reports %s as node-config-refused-by-contract', (escalateTo) => {
103+
const config = {
104+
approvers: [{ type: 'position', value: 'finance_manager' }],
105+
escalation: { timeoutHours: 24, action: 'reassign', escalateTo },
106+
};
107+
expect(flowNodeConfigRefusals('approval', config).map(({ code, path }) => ({ code, path }))).toEqual([
108+
{ code: 'node-config-refused-by-contract', path: 'escalation.escalateTo' },
109+
]);
110+
});
111+
112+
it.each(ACCEPTED)('FlowSchema accepts %s', (escalateTo) => {
113+
expect(FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })).success).toBe(true);
114+
});
115+
});

‎packages/spec/src/automation/approval.zod.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,49 @@ export function normalizeDecisionOutputs(
690690
return out;
691691
}
692692

693+
/**
694+
* `escalateTo` literals no approver can ever act under, refused at
695+
* registration — prefix → the remedy the refusal names.
696+
*
697+
* The approvals service resolves `escalateTo` as a position machine name
698+
* first and falls back to the value as written. A person acts on a slot only
699+
* under their user id, their email or `position:<p>` for a position they hold
700+
* (`approver-address.ts` in `plugin-approvals` is the one source of that
701+
* set), so a `team:` / `department:` / `org_membership_level:` literal, or
702+
* the `role:` spelling ADR-0090 D3 retired, names nobody under any action:
703+
* a `reassign` would move the request onto a slate no one can decide (the
704+
* runtime keeps the slate and notifies instead), and a `notify` would reach
705+
* no one. The shape alone settles it, so the contract refuses it at
706+
* registration rather than leaving the SLA sweep to find it after the fact.
707+
*
708+
* Only the SHAPE is judged here. Whether a user id names an account, or a
709+
* position has a holder, is directory state the runtime judges on timeout.
710+
*/
711+
const DEAD_ESCALATION_TARGET_PREFIXES: Readonly<Record<string, string>> = {
712+
'team:':
713+
'A team is not an escalation target: name a position its members hold, or a person.',
714+
'department:':
715+
'A department is not an escalation target: name a position its head or approvers hold, or a person.',
716+
'org_membership_level:':
717+
'An organization membership tier is not an escalation target: name a position, or a person.',
718+
'role:':
719+
"'role:' is the retired spelling of a position address: write 'position:<name>', or the bare position name.",
720+
};
721+
722+
function checkEscalationTargetShape(value: string, ctx: z.RefinementCtx): void {
723+
const target = value.trim();
724+
const prefix = Object.keys(DEAD_ESCALATION_TARGET_PREFIXES).find((p) => target.startsWith(p));
725+
if (prefix === undefined) return;
726+
ctx.addIssue({
727+
code: 'custom',
728+
message:
729+
`escalateTo '${target}' is a '${prefix}' address, and no approver acts under one, so an `
730+
+ 'escalation to it would hand the request to no one. '
731+
+ `${DEAD_ESCALATION_TARGET_PREFIXES[prefix]} `
732+
+ "escalateTo takes a user id, an email, a position machine name or 'position:<name>'.",
733+
});
734+
}
735+
693736
export const ApprovalEscalationSchema = lazySchema(() => strictObject(
694737
/**
695738
* Per-node SLA escalation — carried on the Approval node itself, so each
@@ -735,8 +778,12 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
735778
// position machine name to its holders via `sys_user_position` (ADR-0090
736779
// D3) and falls back to treating the value as a user id when nobody holds
737780
// it. NOT a better-auth membership tier — same contract as ApproverType.
738-
escalateTo: z.string().optional().meta({
739-
description: 'User id or position machine name to escalate to',
781+
// A literal no approver acts under is refused by its shape
782+
// (DEAD_ESCALATION_TARGET_PREFIXES); whether the rest resolve is runtime state.
783+
escalateTo: z.string().superRefine(checkEscalationTargetShape).optional().meta({
784+
description:
785+
"User id, email, position machine name or 'position:<name>' to escalate to. A 'team:', "
786+
+ "'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one",
740787
xRef: { kind: 'position' },
741788
}),
742789
notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The D3 entry for `ApprovalEscalationSchema.escalateTo` refusing, by shape, a
6+
// target no approver acts under. It narrows a value accept set; no key is
7+
// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is
8+
// no D2 conversion either: which position or person a team, department or
9+
// membership tier was meant to stand for is the author's judgment.
10+
//
11+
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
12+
// inside a code span and a table cell.
13+
export const entry: SemanticMigration = {
14+
id: 'approval-escalation-dead-target-refused',
15+
surface:
16+
'an approval flow node escalation.escalateTo written as a team:, department:, '
17+
+ 'org_membership_level: or role: address (ApprovalEscalationSchema). Reachable wherever a flow '
18+
+ 'is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed '
19+
+ 'to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and '
20+
+ 'a flow row already sitting in sys_metadata',
21+
replacement:
22+
'a target someone can act under: the user id or email of a person, a position machine name, or '
23+
+ '`position:<name>`. For a team, department or membership tier, name a position its people hold '
24+
+ '(or create one and assign them). `role:<name>` is the retired spelling of a position address, '
25+
+ 'so write `position:<name>` or the bare position name',
26+
reason:
27+
'The approvals service resolves `escalateTo` as a position machine name and falls back to the '
28+
+ 'value as written. A person acts on a pending slot only under their user id, their email or '
29+
+ '`position:<p>` for a position they hold, so a `team:`, `department:` or '
30+
+ '`org_membership_level:` literal, or the `role:` spelling ADR-0090 D3 retired, names nobody '
31+
+ 'under any action. A `reassign` escalation used to move the request onto that literal, a slate '
32+
+ 'only the privileged override could decide; the runtime now keeps the slate and notifies it '
33+
+ 'instead, and a `notify` escalation reaches no one through it. The schema accepted the value '
34+
+ 'and every build door exited 0, so the SLA the author declared never did what they meant. '
35+
+ 'The shape alone settles it, so the contract refuses it at registration (`FlowSchema.parse`, '
36+
+ 'the build doors and `AutomationEngine.registerFlow` share that judge); whether a user id names '
37+
+ 'an account or a position has a holder is directory state, judged by the runtime on timeout. '
38+
+ 'No D2 conversion: the platform cannot know which position or person the author meant. Where '
39+
+ 'such a node already sits, the whole flow is refused like any approval node its contract '
40+
+ 'refuses. Ships at once, no grace window. ADR-0087, ADR-0090.',
41+
acceptanceCriteria:
42+
'Run `objectstack validate` over every stack authored in config files, and boot every deployed '
43+
+ 'stack. A refused target is reported at `nodes.N.config.escalation.escalateTo`; replace it per '
44+
+ 'the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` '
45+
+ 'is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` '
46+
+ 'warn for it, the locator for a row that exists only in `sys_metadata`. An escalation whose '
47+
+ 'target is a user id, an email, a position name or `position:<name>` parses and registers '
48+
+ 'byte-identically to before.',
49+
};

0 commit comments

Comments
 (0)