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
15 changes: 15 additions & 0 deletions .changeset/21458-spec-approval-action-acted-as.md
Original file line number Diff line number Diff line change
@@ -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:<name>` address (or `role:<name>`, 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.
49 changes: 47 additions & 2 deletions packages/spec/src/contracts/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string
// | undefined>` 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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;

/** Optional: a row may omit it. A required member turns this alias red. */
export type ActedAsIsOptional = Assert<{} extends Pick<ApprovalActionRow, 'acted_as'> ? true : false>;

/** A string slot address — not a boolean flag, not an object, not nullable. */
export type ActedAsIsAString = Assert<Eq<ApprovalActionRow['acted_as'], string | undefined>>;

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<ApprovalActionRow['acted_as']>().toEqualTypeOf<string | undefined>();

// 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.
Expand All @@ -82,8 +129,6 @@ describe('approval row organization_id declaration (#10331)', () => {
// file.
// ---------------------------------------------------------------------------

type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;
type ContinueRestoredRun = NonNullable<IApprovalService['continueRestoredRun']>;

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`
* address (or `role:<name>`, 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. */
Expand Down
Loading