Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/21517-approval-reassign-slot-address-tsdoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': patch
---

The `ApprovalActionRow` documentation now says what `reassign_from` and `reassign_to` hold. It said both were users. They hold a slot address in its stored spelling: a user id, an email, or a position address such as `position:legal`. A reassignment moves a slot, not necessarily a person, and the person who made the move is `actor_id`. The `reassign_from_name` and `reassign_to_name` documentation now says when a name resolves: only for a user id, or for an email an account carries. A position address never resolves, so a consumer renders the address when the name is absent.

Clause-②: no

Documentation only. No schema, export or type changes.
1 change: 1 addition & 0 deletions docs/adr/0042-approval-sla-escalation.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR-0042: Approval SLA escalation — a jobs-backed scanner with audit-row idempotency

**Status**: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12)
· **Superseded in part (2026-08-02, [ADR-0118](./0118-non-user-actor-contract.md) D1)** — §2's reserved actor `system:sla`, wherever this record names it: machine actions record `actor_id` null, and the `escalate` row is the attribution.
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0019](./0019-approval-as-flow-node.md) (approval as flow node), [ADR-0041](./0041-flow-trigger-family.md) (triggers vs jobs vs hooks — this is the canonical "jobs, not trigger" case), thread interactions (#1740)
**Closes**: [#1742](https://github.com/objectstack-ai/objectstack/issues/1742)
Expand Down
9 changes: 5 additions & 4 deletions docs/qa/platform-checklist/areas/approvals.json
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@
"title": "A node's SLA escalation fires once past its timeout — the declared action runs and an escalate timeline row lands",
"since": "v16",
"status": "active",
"revision": 1,
"revision": 2,
"priority": "P2",
"surface": "api",
"personas": ["dev admin"],
Expand All @@ -832,7 +832,7 @@
"in a writable package author + register a flow whose approval node config.escalation = {enabled:true, timeoutHours:1, action:'reassign', escalateTo:'<position machine name or user id>', notifySubmitter:true}",
"trigger the flow; GET /api/v1/approvals/requests/:id — status=pending and the request carries sla_due_at = created_at + timeoutHours (the SLA is materialized on open)",
"[needs clock control] advance the clock past sla_due_at (inject a clock / drive ApprovalService.runEscalations() with a clock whose now() is beyond the deadline) and run one escalation sweep",
"GET /:id/actions — assert exactly one action='escalate' row (the audit-first idempotency marker, actor SLA_ACTOR_ID) whose comment names the action",
"GET /:id/actions — assert exactly one action='escalate' row (the audit-first idempotency marker) whose comment names the action and which carries no actor: actor_id is absent on the read (stored null), because a machine action records no actor and the escalate row is the attribution (ADR-0118 D1)",
"assert the declared action's effect: reassign → pending_approvers swapped to the escalatees + an approval.escalated notification to them; auto_approve/auto_reject → request finalized approved/rejected and the owning run resumes; notify → an approval.sla_breached notification to the pending approvers",
"with notifySubmitter!==false, read the submitter's inbox — an approval.sla_breached notification addressed to them",
"idempotency: run the sweep a SECOND time — GET /:id/actions shows NO second escalate row (single-shot, marker-guarded)",
Expand All @@ -848,7 +848,7 @@
{
"clause": "past the deadline the sweep escalates exactly ONCE: one action='escalate' timeline row, and a re-run adds none",
"oracle": "api",
"verify": "after advancing past sla_due_at and sweeping, GET /:id/actions has exactly one action='escalate' row (actor SLA_ACTOR_ID); a second sweep adds no further escalate row (the audit row is the idempotency marker, written before any mutation)",
"verify": "after advancing past sla_due_at and sweeping, GET /:id/actions has exactly one action='escalate' row with no actor (actor_id absent on the read, stored null — ADR-0118 D1: a machine action records no actor, and the escalate row is the attribution); a second sweep adds no further escalate row (the audit row is the idempotency marker, written before any mutation)",
"evidence": "actions reads after the first and second sweeps"
},
{
Expand Down Expand Up @@ -882,7 +882,8 @@
"packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (sla_due_at surfaced on the request)"
],
"history": [
{ "revision": 1, "date": "2026-08-08", "change": "initial — pins the ADR-0042 SLA escalation (declared action fires once past timeout + escalate timeline row); blocked on a clock-control timing harness (hour granularity), with the sla_due_at materialization and the strict-schema build clause runnable today", "ref": "claude/platform-test-checklist-ocwugl" }
{ "revision": 1, "date": "2026-08-08", "change": "initial — pins the ADR-0042 SLA escalation (declared action fires once past timeout + escalate timeline row); blocked on a clock-control timing harness (hour granularity), with the sla_due_at materialization and the strict-schema build clause runnable today", "ref": "claude/platform-test-checklist-ocwugl" },
{ "revision": 2, "date": "2026-10-03", "change": "the escalate row's expected actor corrected: step 5 and the single-shot clause asserted actor SLA_ACTOR_ID, but under ADR-0118 D1 the sweep records no actor (actor_id null, absent on the GET /:id/actions read) and the escalate row is the attribution, so a run following the old text would report a false failure. Nothing else in the item changes", "ref": "#21517" }
]
},
{
Expand Down
26 changes: 19 additions & 7 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,19 +480,31 @@ export interface ApprovalActionRow {
/** Display name of the actor (`sys_user.name`), when resolvable. */
actor_name?: string;
/**
* Structured hand-off parties on a `reassign` action (#4365): the user whose
* pending-approver slot was moved, and the user who received it. Previously
* the pair existed only inside a default free-text `comment`
* (`"<from_id> → <to_id>"`), which clients could neither parse nor render
* Structured hand-off parties on a `reassign` action: the pending-approver
* slot that was handed over, and the address it was handed to. A
* reassignment moves a slot, not necessarily a person, so both hold a slot
* address in its stored spelling — a user id, an email, or a position
* address (`position:<name>`; any other `type:value` literal a slate kept
* is stored the same way). Like `acted_as`, neither is a `sys_user`
* reference, and neither makes a claim about who made the move: that
* person is `actor_id`.
*
* Previously the pair existed only inside a default free-text `comment`
* (`"<from> → <to>"`), which clients could neither parse nor render
* readably. `comment` is now pure user input; consumers render the hand-off
* from these fields (via the resolved `*_name` companions below).
* from these fields — through the `*_name` companions below where one
* resolved, and as the address itself where none did.
*/
reassign_from?: string;
/** See {@link ApprovalActionRow.reassign_from}. */
reassign_to?: string;
/** Display name of `reassign_from` (`sys_user.name`), when resolvable. */
/**
* Display name of `reassign_from` (`sys_user.name`). It resolves only when
* the address names an account: a user id, or an email an account carries.
* A position address never resolves, so absent means "render the address".
*/
reassign_from_name?: string;
/** Display name of `reassign_to` (`sys_user.name`), when resolvable. */
/** Display name of `reassign_to`; resolves as {@link ApprovalActionRow.reassign_from_name} does. */
reassign_to_name?: string;
/**
* Whether the actor was admitted to this action ONLY by the privileged
Expand Down
Loading