From 828d3f2db380852dd0ebf10119900624d2c69f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:40:23 +0000 Subject: [PATCH 1/2] feat(spec): ApprovalActionRow declares acted_as, the slot an approval action was taken as One optional string member on the published action-log row type, beside via_override: the pending-approver slot the action was admitted under, in the slot's stored spelling. It is never a person (that is actor_id, per ADR-0118 D1). Absent means not admitted through a slot, or not recorded. Pins: optional (a type alias that a required member turns red), string-typed, and a row without it still conforms. Changeset: @objectstack/spec minor. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../21458-spec-approval-action-acted-as.md | 15 ++++++ .../src/contracts/approval-service.test.ts | 49 ++++++++++++++++++- .../spec/src/contracts/approval-service.ts | 22 +++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 .changeset/21458-spec-approval-action-acted-as.md 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..2d2b33952e8 --- /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 its older `role:

` 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. Absent reads as "not recorded", not as "no slot". + +**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..ddb6e45a789 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -513,6 +513,28 @@ 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 its older `role:

` 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 — "not recorded", which + * is not the same claim as "no slot". + */ + acted_as?: string; } /** Input for a decision on an approval request. */ From d576d5b4c74b4dd9c49fa371968ee4e3c87cb107 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:03:57 +0000 Subject: [PATCH 2/2] docs(spec): spell the acted_as slot forms as the approvals changesets do, and say what absent cannot prove Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/21458-spec-approval-action-acted-as.md | 4 ++-- packages/spec/src/contracts/approval-service.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/21458-spec-approval-action-acted-as.md b/.changeset/21458-spec-approval-action-acted-as.md index 2d2b33952e8..5424509520c 100644 --- a/.changeset/21458-spec-approval-action-acted-as.md +++ b/.changeset/21458-spec-approval-action-acted-as.md @@ -8,8 +8,8 @@ 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 its older `role:

` spelling), an email, or a user id. +- `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. Absent reads as "not recorded", not as "no slot". +- 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.ts b/packages/spec/src/contracts/approval-service.ts index ddb6e45a789..6ed13aee9a8 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -516,8 +516,9 @@ export interface ApprovalActionRow { /** * 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 its older `role:

` spelling), an email, or a user id. + * `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 @@ -531,8 +532,8 @@ export interface ApprovalActionRow { * 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 — "not recorded", which - * is not the same claim as "no slot". + * 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; }