diff --git a/.changeset/21458-spec-approval-action-acted-as.md b/.changeset/21458-spec-approval-action-acted-as.md new file mode 100644 index 00000000000..5424509520c --- /dev/null +++ b/.changeset/21458-spec-approval-action-acted-as.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ApprovalActionRow` declares `acted_as`, the pending-approver slot an approval action was taken as, beside the person in `actor_id` + +Clause-②: yes + +**What it declares.** One optional string member on `ApprovalActionRow` in `@objectstack/spec/contracts`, the row type of an approval request's action log (`IApprovalService.listActions`, served at `GET /api/v1/approvals/requests/:id/actions` and typed by the client SDK): + +- `acted_as?: string` is the slot the action was admitted under, in the slot's stored spelling as it stood in the request's `pending_approvers`: a `position:` address (or `role:`, the deprecated pre-rename spelling), an email, or a user id. +- It is never a person. The person who acted is `actor_id`, which under ADR-0118 D1 holds a `sys_user` id or nothing. A slot addressed by a user id carries that id in `acted_as` as the slot's address, which makes no claim about who acted. +- Absent means the action was not admitted through a slot (a submitter's own action, a system action, or an admin override, which `via_override` marks), or the row was written before the slot was recorded. So absent alone never proves that no slot was involved. + +**What moves for consumers.** Nothing in this package writes the member, and every existing export and member is unchanged: a row without `acted_as` conforms exactly as before. The approvals service is its producer, and that package's own changeset states when `listActions` starts returning it. Until then every row omits it, which is the member's declared absent case. A client that renders the action log can show `acted_as` beside the actor's name as the capacity the actor acted in. diff --git a/packages/spec/src/contracts/approval-service.test.ts b/packages/spec/src/contracts/approval-service.test.ts index 1f882051eea..a3a7e9281cf 100644 --- a/packages/spec/src/contracts/approval-service.test.ts +++ b/packages/spec/src/contracts/approval-service.test.ts @@ -63,6 +63,53 @@ describe('approval row organization_id declaration (#10331)', () => { }); }); +// --------------------------------------------------------------------------- +// [#21458] `ApprovalActionRow.acted_as` — the slot an action was taken as. +// +// Triage ruled the person into `actor_id` and the slot into a field of its own +// (#21411 ruling B); the action log shows the slot, so the published row type +// declares it. The pins hold what the card decided: the member is OPTIONAL and +// a STRING, and a row without it (every producer that does not write it, and +// every row written before it existed) still conforms. +// +// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf` cannot tell `acted_as?: string` from a REQUIRED `acted_as: string +// | undefined`, while `{} extends Pick<…>` can. Exported for the same TS6196 +// reason the aliases below are; `check:test-typecheck` compiles this file. +// --------------------------------------------------------------------------- + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; + +/** Optional: a row may omit it. A required member turns this alias red. */ +export type ActedAsIsOptional = Assert<{} extends Pick ? true : false>; + +/** A string slot address — not a boolean flag, not an object, not nullable. */ +export type ActedAsIsAString = Assert>; + +describe('[#21458] ApprovalActionRow.acted_as — the slot an approval action was taken as', () => { + it('is optional and string-typed: a row without it still conforms, and one with it reads back', () => { + const read = (row: ApprovalActionRow): string | undefined => row.acted_as; + + expectTypeOf().toEqualTypeOf(); + + // The shape every existing producer returns: no slot member at all. + // This literal failing to compile is the regression. + const withoutSlot: ApprovalActionRow = { + id: 'aact_1', + request_id: 'req_1', + action: 'approve', + actor_id: 'usr_holder', + }; + expect(read(withoutSlot)).toBeUndefined(); + + // The person and the slot side by side, each in its own member. + const withSlot: ApprovalActionRow = { ...withoutSlot, acted_as: 'position:finance' }; + expect(read(withSlot)).toBe('position:finance'); + expect(withSlot.actor_id).toBe('usr_holder'); + }); +}); + // --------------------------------------------------------------------------- // [#15389] `continueRestoredRun` — the approvals half of the operator repair // pair, declared on the contract. @@ -82,8 +129,6 @@ describe('approval row organization_id declaration (#10331)', () => { // file. // --------------------------------------------------------------------------- -type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -type Assert = T; type ContinueRestoredRun = NonNullable; /** diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index a04643ef055..6ed13aee9a8 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -513,6 +513,29 @@ export interface ApprovalActionRow { * predates the column — "not recorded", which is not the same claim. */ via_override?: boolean; + /** + * The pending-approver slot this action was taken as: the slot's address in + * its stored spelling, exactly as it stood in the request's + * `pending_approvers` when the action was admitted — a `position:` + * address (or `role:`, the deprecated pre-rename spelling), an email, + * or a user id. + * + * "Who acted" and "as which slot" are two facts. One holder of a position + * can act for it, and one person can hold several slots, so the slot is + * recorded beside the person rather than in place of them. It is what a + * timeline shows as the capacity an approver acted in. + * + * It is never a person. The person who acted is `actor_id` (a `sys_user` id + * or nothing, ADR-0118 D1). A slot addressed by a user id carries that id + * here as the slot's address, which makes no claim about who acted. + * + * Absent means one of two things, and this member alone does not tell them + * apart: the action was not admitted through a slot (a submitter's own + * action, a system action, or an admin override — see `via_override`), or + * the row was written before the slot was recorded. So absent alone never + * proves that no slot was involved: "not recorded" is not the same claim. + */ + acted_as?: string; } /** Input for a decision on an approval request. */