@company-semantics/contracts 101.1.0 → 102.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "101.1.0",
3
+ "version": "102.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ACL leader access (AclListResponse.leader_access)
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - LEADER_ACCESS_BASES is exactly ancestor_lead, ancestor_delegate, account_owner
6
+ * - leader_access is REQUIRED on every ACL list response (no default)
7
+ * - leader_access names each person at most once
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import {
11
+ AclListResponseSchema,
12
+ LEADER_ACCESS_BASES,
13
+ } from "../permissions/index";
14
+
15
+ const base = {
16
+ entity_type: "company_md",
17
+ entity_id: "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
18
+ owner_user_ids: ["11111111-1111-4111-8111-111111111111"],
19
+ visibility: "private",
20
+ general_access_role: "viewer",
21
+ grants: [],
22
+ };
23
+
24
+ describe("ACL leader access", () => {
25
+ it("names exactly the three leader-access bases", () => {
26
+ expect([...LEADER_ACCESS_BASES]).toEqual([
27
+ "ancestor_lead",
28
+ "ancestor_delegate",
29
+ "account_owner",
30
+ ]);
31
+ });
32
+
33
+ it("requires leader_access on every ACL list response", () => {
34
+ expect(() => AclListResponseSchema.parse(base)).toThrow();
35
+ expect(
36
+ AclListResponseSchema.parse({ ...base, leader_access: [] }).leader_access,
37
+ ).toEqual([]);
38
+ });
39
+
40
+ it("refuses a duplicate person in leader_access", () => {
41
+ const row = {
42
+ user_id: "22222222-2222-4222-8222-222222222222",
43
+ basis: "ancestor_lead",
44
+ via_unit_id: "33333333-3333-4333-8333-333333333333",
45
+ via_unit_name: "Finance",
46
+ };
47
+ expect(() =>
48
+ AclListResponseSchema.parse({ ...base, leader_access: [row, row] }),
49
+ ).toThrow();
50
+ });
51
+ });
@@ -67,14 +67,15 @@ absent from the next read. There is no clearing code to forget to write.
67
67
 
68
68
  ## Kinds
69
69
 
70
- | Kind | Derived from | Resolved at |
71
- | ---------------------------------- | ------------------------------------------------------- | -------------------------------------------- |
72
- | `companyMd.access_request_pending` | A pending access request on a company.md document | The document's share dialog |
73
- | `execution.confirmation_pending` | A chat execution in `pending_confirmation` | Its confirm / reject routes |
74
- | `structure.owner_grant_pending` | A lead → owner grant the bridge could not mint | The unit's owners dialog |
75
- | `structure.finding_review_pending` | An open row of `structure_findings` | The finding sheet in Settings → Integrations |
76
- | `sourceSync.approval_pending` | A draft candidate set whose scope approvals are pending | The Drive settings panel that reviews it |
77
- | `sourceSync.proposal_pending` | A validated synthesis batch with a document `proposed` | The synthesis review |
70
+ | Kind | Derived from | Resolved at |
71
+ | ---------------------------------------- | --------------------------------------------------------- | -------------------------------------------- |
72
+ | `companyMd.access_request_pending` | A pending access request on a company.md document | The document's share dialog |
73
+ | `execution.confirmation_pending` | A chat execution in `pending_confirmation` | Its confirm / reject routes |
74
+ | `structure.owner_grant_pending` | A lead → owner grant the bridge could not mint | The unit's owners dialog |
75
+ | `structure.finding_review_pending` | An open row of `structure_findings` | The finding sheet in Settings → Integrations |
76
+ | `sourceSync.approval_pending` | A draft candidate set whose scope approvals are pending | The Drive settings panel that reviews it |
77
+ | `sourceSync.proposal_pending` | A validated synthesis batch with a document `proposed` | The synthesis review |
78
+ | `sourceSync.approver_assignment_pending` | A Source Sync scope with waiting work and nobody assigned | The unit's settings page (owners dialog) |
78
79
 
79
80
  ## Public API
80
81
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Approver-needed vocabulary
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - sourceSync.approver_assignment_pending is appended LAST to ACTION_ITEM_KINDS
6
+ * - an approver-needed item anchored on a unit parses as an ActionItem
7
+ * - scope_assignee is appended LAST to PROACTIVE_RECIPIENT_REASONS
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { ACTION_ITEM_KINDS } from "../kinds.js";
11
+ import { ActionItemSchema } from "../schemas.js";
12
+ import { PROACTIVE_RECIPIENT_REASONS } from "../../proactive/kinds.js";
13
+
14
+ describe("approver-needed vocabulary", () => {
15
+ it("appends sourceSync.approver_assignment_pending as the last action item kind", () => {
16
+ expect(ACTION_ITEM_KINDS[ACTION_ITEM_KINDS.length - 1]).toBe(
17
+ "sourceSync.approver_assignment_pending",
18
+ );
19
+ });
20
+
21
+ it("parses an approver-needed item anchored on a unit", () => {
22
+ const item = ActionItemSchema.parse({
23
+ id: "sourceSync.approver_assignment_pending:unit:7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
24
+ kind: "sourceSync.approver_assignment_pending",
25
+ target: { type: "org_unit", id: "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" },
26
+ unitPath: "root.sales",
27
+ title: "Sales needs an approver",
28
+ detail: "3 files are waiting. Assign a lead or add a delegate.",
29
+ contextLabel: "Sales",
30
+ provider: "google_drive",
31
+ href: "/@acme/team/sales/settings",
32
+ createdAt: "2026-10-12T00:00:00.000Z",
33
+ });
34
+ expect(item.kind).toBe("sourceSync.approver_assignment_pending");
35
+ });
36
+
37
+ it("appends scope_assignee as the last proactive recipient reason", () => {
38
+ expect(
39
+ PROACTIVE_RECIPIENT_REASONS[PROACTIVE_RECIPIENT_REASONS.length - 1],
40
+ ).toBe("scope_assignee");
41
+ });
42
+ });
@@ -115,6 +115,19 @@
115
115
  * `sourceSync.review_ready` proactive kind: that kind announces the review
116
116
  * ONCE; this item stays until the review is done. The backend provider is
117
117
  * PRD-01029's.
118
+ *
119
+ * `sourceSync.approver_assignment_pending` passes them too. A Source Sync scope with work
120
+ * waiting (an open scope of a draft candidate set, a proposed scope of a
121
+ * validated synthesis batch, or a suspended standing order) whose assignment is
122
+ * `unassigned` is waiting on somebody to make a responsible person exist: assign
123
+ * the unit a lead or add a delegate. That is administration, not approval, so it
124
+ * is owed by the people who manage the org (an org admin or an account owner),
125
+ * who gain NO access to the work by resolving it. A provider can derive it from
126
+ * the same rows the two other Source Sync kinds read plus the live assignment; the
127
+ * `href` is the unit's own settings page, where the owners dialog assigns a lead
128
+ * or adds a delegate, and a scope that becomes assigned (or whose work is done) is
129
+ * absent from the next read. The item names the unit and a count only: never a
130
+ * document title, a file name or a source.
118
131
  */
119
132
  export const ACTION_ITEM_KINDS = [
120
133
  "companyMd.access_request_pending",
@@ -123,6 +136,7 @@ export const ACTION_ITEM_KINDS = [
123
136
  "structure.finding_review_pending",
124
137
  "sourceSync.approval_pending",
125
138
  "sourceSync.proposal_pending",
139
+ "sourceSync.approver_assignment_pending",
126
140
  ] as const;
127
141
 
128
142
  export type ActionItemKind = (typeof ACTION_ITEM_KINDS)[number];
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '2a364932e5ca' as const;
3
- export const SPEC_HASH_FULL = '2a364932e5ca5fb988423a06d4c58a57c255317be05c842237ce045260d2dd93' as const;
2
+ export const SPEC_HASH = '1edc559559e5' as const;
3
+ export const SPEC_HASH_FULL = '1edc559559e5cd64ba5b3d4d3c7a61822aa00f0d63ae30d297c970f3cdf5103e' as const;
@@ -5280,7 +5280,7 @@ export interface paths {
5280
5280
  };
5281
5281
  /**
5282
5282
  * List the org’s source sync standing orders
5283
- * @description Every standing order — active, suspended with the reason it stopped acting, and revoked — with the grantor’s display name when the grantor is still a member.
5283
+ * @description Every standing order — active, suspended with the reason it stopped acting, and revoked — with the grantor’s display name when the grantor is still a member — and the caller’s grantability (SourceSyncStandingOrderGrantability) for the company scope and each active or suspended order’s scope, at most 10: whether the caller could grant that scope’s order and, when not, why (needs_approval_authority before needs_doc_access). A scope not listed is undecided; the grant decides.
5284
5284
  */
5285
5285
  get: operations["listSourceSyncStandingOrders"];
5286
5286
  put?: never;
@@ -9023,7 +9023,7 @@ export interface components {
9023
9023
  items: {
9024
9024
  id: string;
9025
9025
  /** @enum {string} */
9026
- kind: "companyMd.access_request_pending" | "execution.confirmation_pending" | "structure.owner_grant_pending" | "structure.finding_review_pending" | "sourceSync.approval_pending" | "sourceSync.proposal_pending";
9026
+ kind: "companyMd.access_request_pending" | "execution.confirmation_pending" | "structure.owner_grant_pending" | "structure.finding_review_pending" | "sourceSync.approval_pending" | "sourceSync.proposal_pending" | "sourceSync.approver_assignment_pending";
9027
9027
  target: {
9028
9028
  /** @enum {string} */
9029
9029
  type: "company_md" | "org_unit" | "org" | "execution";
@@ -9157,7 +9157,7 @@ export interface components {
9157
9157
  item: {
9158
9158
  id: string;
9159
9159
  /** @enum {string} */
9160
- kind: "companyMd.access_request_pending" | "execution.confirmation_pending" | "structure.owner_grant_pending" | "structure.finding_review_pending" | "sourceSync.approval_pending" | "sourceSync.proposal_pending";
9160
+ kind: "companyMd.access_request_pending" | "execution.confirmation_pending" | "structure.owner_grant_pending" | "structure.finding_review_pending" | "sourceSync.approval_pending" | "sourceSync.proposal_pending" | "sourceSync.approver_assignment_pending";
9161
9161
  target: {
9162
9162
  /** @enum {string} */
9163
9163
  type: "company_md" | "org_unit" | "org" | "execution";
@@ -10840,13 +10840,21 @@ export interface components {
10840
10840
  scopeKey: string;
10841
10841
  /** @enum {string} */
10842
10842
  state: "pending" | "approved" | "rejected";
10843
- /** @enum {string} */
10844
- requiredApproverKind: "org_admin" | "unit_head";
10845
- assignedTo: {
10846
- label: string;
10847
- isCaller: boolean;
10848
- } | null;
10849
- vacantHead: boolean;
10843
+ assignment: {
10844
+ /** @constant */
10845
+ status: "assigned";
10846
+ assignees: {
10847
+ label: string;
10848
+ isCaller: boolean;
10849
+ /** @enum {string} */
10850
+ basis: "lead" | "delegate";
10851
+ }[];
10852
+ } | {
10853
+ /** @constant */
10854
+ status: "unassigned";
10855
+ /** @enum {string} */
10856
+ reason: "no_responsible_person" | "no_eligible_approver";
10857
+ };
10850
10858
  approvedBy: {
10851
10859
  label: string;
10852
10860
  } | null;
@@ -10906,13 +10914,21 @@ export interface components {
10906
10914
  scopeKey: string;
10907
10915
  /** @enum {string} */
10908
10916
  state: "pending" | "approved" | "rejected";
10909
- /** @enum {string} */
10910
- requiredApproverKind: "org_admin" | "unit_head";
10911
- assignedTo: {
10912
- label: string;
10913
- isCaller: boolean;
10914
- } | null;
10915
- vacantHead: boolean;
10917
+ assignment: {
10918
+ /** @constant */
10919
+ status: "assigned";
10920
+ assignees: {
10921
+ label: string;
10922
+ isCaller: boolean;
10923
+ /** @enum {string} */
10924
+ basis: "lead" | "delegate";
10925
+ }[];
10926
+ } | {
10927
+ /** @constant */
10928
+ status: "unassigned";
10929
+ /** @enum {string} */
10930
+ reason: "no_responsible_person" | "no_eligible_approver";
10931
+ };
10916
10932
  approvedBy: {
10917
10933
  label: string;
10918
10934
  } | null;
@@ -11035,13 +11051,21 @@ export interface components {
11035
11051
  scopeKey: string;
11036
11052
  /** @enum {string} */
11037
11053
  state: "pending" | "approved" | "rejected";
11038
- /** @enum {string} */
11039
- requiredApproverKind: "org_admin" | "unit_head";
11040
- assignedTo: {
11041
- label: string;
11042
- isCaller: boolean;
11043
- } | null;
11044
- vacantHead: boolean;
11054
+ assignment: {
11055
+ /** @constant */
11056
+ status: "assigned";
11057
+ assignees: {
11058
+ label: string;
11059
+ isCaller: boolean;
11060
+ /** @enum {string} */
11061
+ basis: "lead" | "delegate";
11062
+ }[];
11063
+ } | {
11064
+ /** @constant */
11065
+ status: "unassigned";
11066
+ /** @enum {string} */
11067
+ reason: "no_responsible_person" | "no_eligible_approver";
11068
+ };
11045
11069
  approvedBy: {
11046
11070
  label: string;
11047
11071
  } | null;
@@ -11096,13 +11120,21 @@ export interface components {
11096
11120
  scopeKey: string;
11097
11121
  /** @enum {string} */
11098
11122
  state: "pending" | "approved" | "rejected";
11099
- /** @enum {string} */
11100
- requiredApproverKind: "org_admin" | "unit_head";
11101
- assignedTo: {
11102
- label: string;
11103
- isCaller: boolean;
11104
- } | null;
11105
- vacantHead: boolean;
11123
+ assignment: {
11124
+ /** @constant */
11125
+ status: "assigned";
11126
+ assignees: {
11127
+ label: string;
11128
+ isCaller: boolean;
11129
+ /** @enum {string} */
11130
+ basis: "lead" | "delegate";
11131
+ }[];
11132
+ } | {
11133
+ /** @constant */
11134
+ status: "unassigned";
11135
+ /** @enum {string} */
11136
+ reason: "no_responsible_person" | "no_eligible_approver";
11137
+ };
11106
11138
  approvedBy: {
11107
11139
  label: string;
11108
11140
  } | null;
@@ -21863,7 +21895,7 @@ export interface operations {
21863
21895
  };
21864
21896
  content?: never;
21865
21897
  };
21866
- /** @description The caller does not hold the authority the order would exercise */
21898
+ /** @description The caller does not hold the authority the order would exercise, or cannot act on the scope’s document (SOURCE_SYNC_SCOPE_DOC_ACCESS_DENIED) */
21867
21899
  403: {
21868
21900
  headers: {
21869
21901
  [name: string]: unknown;
@@ -82,6 +82,32 @@ export const AclGrantResponseSchema = z.object({
82
82
  });
83
83
  export type AclGrantResponse = z.infer<typeof AclGrantResponseSchema>;
84
84
 
85
+ /**
86
+ * Why a person who is NOT an owner and holds no grant on this document can
87
+ * still reach it. Structural, never a grant row: inherited unit authority
88
+ * (an ancestor unit's lead or delegate holds the `unit_editor` band on every
89
+ * descendant document) and account ownership. Document sharing CANNOT remove
90
+ * these: there are no deny rows, so a "private" company.md document is still
91
+ * readable and editable by them. The share dialog lists them so it never implies
92
+ * otherwise.
93
+ */
94
+ export const LEADER_ACCESS_BASES = [
95
+ "ancestor_lead",
96
+ "ancestor_delegate",
97
+ "account_owner",
98
+ ] as const;
99
+ export type LeaderAccessBasis = (typeof LEADER_ACCESS_BASES)[number];
100
+
101
+ export const LeaderAccessSchema = z.object({
102
+ user_id: z.string().uuid(),
103
+ basis: z.enum(LEADER_ACCESS_BASES),
104
+ /** The ancestor unit the authority comes from; null for `account_owner`. */
105
+ via_unit_id: z.string().uuid().nullable(),
106
+ /** That unit's name, for "Leads Finance"; null for `account_owner`. */
107
+ via_unit_name: z.string().min(1).nullable(),
108
+ });
109
+ export type LeaderAccess = z.infer<typeof LeaderAccessSchema>;
110
+
85
111
  export const AclListResponseSchema = z.object({
86
112
  entity_type: z.string(),
87
113
  entity_id: z.string().uuid(),
@@ -98,6 +124,19 @@ export const AclListResponseSchema = z.object({
98
124
  owner_user_ids: z
99
125
  .array(z.string().uuid())
100
126
  .refine(uniqueValues, { message: "duplicate id in owner_user_ids" }),
127
+ /**
128
+ * Everyone who reaches this entity through structure rather than ownership or a
129
+ * grant, one entry per person, never someone already in `owner_user_ids`.
130
+ * company_md only; every other entity type sends `[]`.
131
+ */
132
+ leader_access: z
133
+ .array(LeaderAccessSchema)
134
+ .refine(
135
+ (rows) => new Set(rows.map((r) => r.user_id)).size === rows.length,
136
+ {
137
+ message: "duplicate user_id in leader_access",
138
+ },
139
+ ),
101
140
  /**
102
141
  * General-access scope: WHO may read. NOT discoverability — see
103
142
  * `discoverability`.
@@ -373,6 +373,13 @@ export const PROACTIVE_RECIPIENT_REASONS = [
373
373
  "subject",
374
374
  /** Addressed because they took part in the thing that happened. */
375
375
  "participant",
376
+ /**
377
+ * Addressed because a Source Sync scope's approval is ASSIGNED to them: a
378
+ * local lead or active delegate of the scope's unit (the root unit for the
379
+ * company scope). Assignment, not authority: an account owner or an inherited
380
+ * leader who may also approve is not addressed for it.
381
+ */
382
+ "scope_assignee",
376
383
  ] as const;
377
384
  export type ProactiveRecipientReason =
378
385
  (typeof PROACTIVE_RECIPIENT_REASONS)[number];
@@ -50,8 +50,16 @@ way. This directory holds that shared spelling.
50
50
  - **A caller sees only their scopes' states.** Every checkpoint response (the
51
51
  candidate set, approve, the synthesis, apply) carries `scopes`: one
52
52
  `SourceSyncScopeApprovalProjection` per scope the caller may see, naming
53
- people by label only, and nothing about any other scope. `vacantHead` says a
54
- unit has no head (still `unit_head`, no assignee: admins approve it).
53
+ people by label only, and nothing about any other scope.
54
+ - **A scope is assigned, not routed.** `assignment` is either `assigned` to the
55
+ scope unit's local leads and active delegates who can act on the document
56
+ (the root unit for the company scope), or `unassigned` with a typed reason
57
+ (`no_responsible_person`, `no_eligible_approver`). Nobody is assigned by
58
+ virtue of an org role: a vacancy is a coordination task, never a reason to
59
+ widen who decides. Assignment is not eligibility: the caller's own
60
+ `approvable` / `blockedReason` say whether they may approve. A pending scope
61
+ reports its assignment as of now; an approved one the assignment its
62
+ approval recorded.
55
63
  - **A withheld source says so.** A candidate item, a change's source and a
56
64
  decision evidence row carry a required `withheld`; a withheld source is named
57
65
  by its class and has no link or owner. A client reads the flag, never the copy.
@@ -279,8 +287,11 @@ in `attention-evidence.ts`.
279
287
  to a proposed document change.
280
288
  - `SOURCE_SYNC_SCOPE_APPROVAL_STATES` / `SourceSyncScopeApprovalState` —
281
289
  `pending`, `approved` or `rejected` (no verb writes `rejected` yet).
282
- - `SOURCE_SYNC_SCOPE_APPROVER_KINDS` / `SourceSyncScopeApproverKind` —
283
- `org_admin` or `unit_head`.
290
+ - `SOURCE_SYNC_SCOPE_ASSIGNEE_BASES` / `SourceSyncScopeAssigneeBasis` — why a
291
+ person is assigned a scope: `lead` (a local lead of the scope's unit) or
292
+ `delegate` (an active delegate of it).
293
+ - `SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS` / `SourceSyncScopeUnassignedReason` —
294
+ why nobody is assigned: `no_responsible_person` or `no_eligible_approver`.
284
295
  - `SOURCE_SYNC_SCOPE_BLOCKED_REASONS` / `SourceSyncScopeBlockedReason` — why
285
296
  the caller may not approve a scope or grant its standing order:
286
297
  `needs_approval_authority` (checked first) or `needs_doc_access`.
@@ -339,8 +350,13 @@ in `attention-evidence.ts`.
339
350
  - `SourceSyncScopeApprovalSchema` / `SourceSyncScopeApproval` — one approval
340
351
  row: one scope bound to its own `candidateSetHash`.
341
352
  - `SourceSyncScopeApprovalProjectionSchema` / `SourceSyncScopeApprovalProjection`
342
- — one scope's approval as the caller may see it: state, approver kind,
343
- assignee (`isCaller`), `vacantHead`, approver and time.
353
+ — one scope's approval as the caller may see it: state, `assignment`,
354
+ approver and time, and whether the caller may approve it.
355
+ - `SourceSyncScopeAssigneeSchema` / `SourceSyncScopeAssignee` — one assignee:
356
+ label, `isCaller` and `basis`.
357
+ - `SourceSyncScopeAssignmentSchema` / `SourceSyncScopeAssignment` — whom a
358
+ scope is assigned to: `assigned` (1..50 assignees) or `unassigned` with a
359
+ reason, discriminated on `status`.
344
360
  - `SourceSyncApproveResponseSchema` / `SourceSyncApproveResponse` — the
345
361
  approval as recorded, one row per scope approved in the call, every visible
346
362
  scope's state, and the synthesis workflow started.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Source Sync scope assignment wire tests.
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - Assignees are named by basis `lead` or `delegate` only; there is no admin basis
6
+ * - An unassigned scope carries a typed reason, never an admin fallback
7
+ * - An assigned scope names at least one assignee
8
+ * - The retired routing fields (requiredApproverKind, vacantHead) are not part of
9
+ * the projection
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ SOURCE_SYNC_SCOPE_ASSIGNEE_BASES,
15
+ SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS,
16
+ SourceSyncScopeApprovalProjectionSchema,
17
+ SourceSyncScopeAssignmentSchema,
18
+ } from "../index";
19
+
20
+ const pending = (assignment: unknown) => ({
21
+ scopeKey: "unit:7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
22
+ state: "pending",
23
+ assignment,
24
+ approvedBy: null,
25
+ approvedAt: null,
26
+ approvable: true,
27
+ blockedReason: null,
28
+ });
29
+
30
+ describe("scope assignment wire", () => {
31
+ it("names exactly the assignee bases lead and delegate", () => {
32
+ expect([...SOURCE_SYNC_SCOPE_ASSIGNEE_BASES]).toEqual(["lead", "delegate"]);
33
+ });
34
+
35
+ it("names exactly the unassigned reasons", () => {
36
+ expect([...SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS]).toEqual([
37
+ "no_responsible_person",
38
+ "no_eligible_approver",
39
+ ]);
40
+ });
41
+
42
+ it("accepts an assigned scope with a lead and a delegate", () => {
43
+ const parsed = SourceSyncScopeApprovalProjectionSchema.parse(
44
+ pending({
45
+ status: "assigned",
46
+ assignees: [
47
+ { label: "Maya Chen", isCaller: false, basis: "lead" },
48
+ { label: "Sam Lee", isCaller: true, basis: "delegate" },
49
+ ],
50
+ }),
51
+ );
52
+ expect(parsed.assignment.status).toBe("assigned");
53
+ });
54
+
55
+ it("refuses an assigned scope with no assignees", () => {
56
+ expect(() =>
57
+ SourceSyncScopeAssignmentSchema.parse({
58
+ status: "assigned",
59
+ assignees: [],
60
+ }),
61
+ ).toThrow();
62
+ });
63
+
64
+ it("accepts an unassigned scope with a typed reason and refuses an untyped one", () => {
65
+ expect(
66
+ SourceSyncScopeAssignmentSchema.parse({
67
+ status: "unassigned",
68
+ reason: "no_responsible_person",
69
+ }).status,
70
+ ).toBe("unassigned");
71
+ expect(() =>
72
+ SourceSyncScopeAssignmentSchema.parse({
73
+ status: "unassigned",
74
+ reason: "org_admins",
75
+ }),
76
+ ).toThrow();
77
+ });
78
+
79
+ it("refuses the retired routing fields", () => {
80
+ const legacy = {
81
+ ...pending({ status: "unassigned", reason: "no_responsible_person" }),
82
+ requiredApproverKind: "org_admin",
83
+ vacantHead: true,
84
+ };
85
+ const strict = SourceSyncScopeApprovalProjectionSchema.strict();
86
+ expect(() => strict.parse(legacy)).toThrow();
87
+ const declared = Object.keys(SourceSyncScopeApprovalProjectionSchema.shape);
88
+ for (const retired of [
89
+ "requiredApproverKind",
90
+ "assignedTo",
91
+ "vacantHead",
92
+ ]) {
93
+ expect(declared).not.toContain(retired);
94
+ }
95
+ });
96
+ });
@@ -2,9 +2,11 @@
2
2
  * Source Sync per-scope approval projection and withheld-source marker tests.
3
3
  *
4
4
  * INVARIANTS TESTED:
5
- * - A scope projection names people by LABEL only; its states and approver kinds
6
- * are the closed stored vocabularies
7
- * - A vacant unit scope is a `unit_head` scope with no assignee and `vacantHead: true`
5
+ * - A scope projection names people by LABEL only; its states, assignee bases and
6
+ * unassigned reasons are closed vocabularies
7
+ * - A scope with no local lead or active delegate is `unassigned` with a typed
8
+ * reason; nothing on the wire names an admin to fall back to
9
+ * - `assignment` is REQUIRED on every projection
8
10
  * - Every read and write response of the checkpoint carries `scopes` (required)
9
11
  * - `withheld` is REQUIRED on a candidate item, a change's source and an evidence
10
12
  * row: a producer that forgets it fails to parse, never defaults to shown
@@ -29,9 +31,10 @@ function scope(overrides: Record<string, unknown> = {}) {
29
31
  return {
30
32
  scopeKey: UNIT_SCOPE,
31
33
  state: "pending",
32
- requiredApproverKind: "unit_head",
33
- assignedTo: { label: "Maya Chen", isCaller: false },
34
- vacantHead: false,
34
+ assignment: {
35
+ status: "assigned",
36
+ assignees: [{ label: "Maya Chen", isCaller: false, basis: "lead" }],
37
+ },
35
38
  approvedBy: null,
36
39
  approvedAt: null,
37
40
  approvable: true,
@@ -41,25 +44,42 @@ function scope(overrides: Record<string, unknown> = {}) {
41
44
  }
42
45
 
43
46
  describe("SourceSyncScopeApprovalProjectionSchema", () => {
44
- it("accepts a pending unit scope routed to its head", () => {
47
+ it("accepts a pending unit scope assigned to its lead", () => {
45
48
  expect(
46
49
  SourceSyncScopeApprovalProjectionSchema.safeParse(scope()).success,
47
50
  ).toBe(true);
48
51
  });
49
52
 
50
- it("accepts a vacant unit scope: unit_head, no assignee, vacantHead", () => {
53
+ it("accepts a unit scope with no lead or delegate: unassigned, no_responsible_person", () => {
51
54
  const parsed = SourceSyncScopeApprovalProjectionSchema.safeParse(
52
- scope({ assignedTo: null, vacantHead: true }),
55
+ scope({
56
+ assignment: { status: "unassigned", reason: "no_responsible_person" },
57
+ }),
53
58
  );
54
59
  expect(parsed.success).toBe(true);
55
60
  });
56
61
 
57
- it("accepts the company scope routed to the org's admins", () => {
62
+ it("accepts the company scope assigned to the root unit's leads and delegates", () => {
58
63
  const parsed = SourceSyncScopeApprovalProjectionSchema.safeParse(
59
64
  scope({
60
65
  scopeKey: "company",
61
- requiredApproverKind: "org_admin",
62
- assignedTo: null,
66
+ assignment: {
67
+ status: "assigned",
68
+ assignees: [
69
+ { label: "Maya Chen", isCaller: false, basis: "lead" },
70
+ { label: "Sam Lee", isCaller: true, basis: "delegate" },
71
+ ],
72
+ },
73
+ }),
74
+ );
75
+ expect(parsed.success).toBe(true);
76
+ });
77
+
78
+ it("accepts the company scope unassigned when no root lead can act on the document", () => {
79
+ const parsed = SourceSyncScopeApprovalProjectionSchema.safeParse(
80
+ scope({
81
+ scopeKey: "company",
82
+ assignment: { status: "unassigned", reason: "no_eligible_approver" },
63
83
  }),
64
84
  );
65
85
  expect(parsed.success).toBe(true);
@@ -76,7 +96,7 @@ describe("SourceSyncScopeApprovalProjectionSchema", () => {
76
96
  expect(parsed.success).toBe(true);
77
97
  });
78
98
 
79
- it("refuses a state or approver kind outside the stored vocabulary", () => {
99
+ it("refuses a state, assignee basis or unassigned reason outside the vocabulary", () => {
80
100
  expect(
81
101
  SourceSyncScopeApprovalProjectionSchema.safeParse(
82
102
  scope({ state: "waiting" }),
@@ -84,20 +104,38 @@ describe("SourceSyncScopeApprovalProjectionSchema", () => {
84
104
  ).toBe(false);
85
105
  expect(
86
106
  SourceSyncScopeApprovalProjectionSchema.safeParse(
87
- scope({ requiredApproverKind: "member" }),
107
+ scope({
108
+ assignment: {
109
+ status: "assigned",
110
+ assignees: [
111
+ { label: "Maya Chen", isCaller: false, basis: "admin" },
112
+ ],
113
+ },
114
+ }),
115
+ ).success,
116
+ ).toBe(false);
117
+ expect(
118
+ SourceSyncScopeApprovalProjectionSchema.safeParse(
119
+ scope({ assignment: { status: "unassigned", reason: "vacant_head" } }),
88
120
  ).success,
89
121
  ).toBe(false);
90
122
  });
91
123
 
92
- it("refuses an empty label and a missing vacantHead", () => {
124
+ it("refuses an empty assignee label and a missing assignment", () => {
93
125
  expect(
94
126
  SourceSyncScopeApprovalProjectionSchema.safeParse(
95
- scope({ assignedTo: { label: "", isCaller: true } }),
127
+ scope({
128
+ assignment: {
129
+ status: "assigned",
130
+ assignees: [{ label: "", isCaller: true, basis: "lead" }],
131
+ },
132
+ }),
96
133
  ).success,
97
134
  ).toBe(false);
98
- const { vacantHead: _omit, ...withoutVacant } = scope();
135
+ const { assignment: _omit, ...withoutAssignment } = scope();
99
136
  expect(
100
- SourceSyncScopeApprovalProjectionSchema.safeParse(withoutVacant).success,
137
+ SourceSyncScopeApprovalProjectionSchema.safeParse(withoutAssignment)
138
+ .success,
101
139
  ).toBe(false);
102
140
  });
103
141
  });
@@ -6,7 +6,7 @@
6
6
  * order.
7
7
  *
8
8
  * INVARIANTS TESTED:
9
- * - Each of the twenty-five closed sets (eleven Source Sync, thirteen discovery
9
+ * - Each of the twenty-six closed sets (twelve Source Sync, thirteen discovery
10
10
  * relevance, one attention evidence) has exactly the members this release
11
11
  * fixes
12
12
  * - `decoy` is a source class; `unknown` is a sharing breadth
@@ -58,7 +58,8 @@ import {
58
58
  SOURCE_SYNC_DOC_RISKS,
59
59
  SOURCE_SYNC_EDIT_RISKS,
60
60
  SOURCE_SYNC_SCOPE_APPROVAL_STATES,
61
- SOURCE_SYNC_SCOPE_APPROVER_KINDS,
61
+ SOURCE_SYNC_SCOPE_ASSIGNEE_BASES,
62
+ SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS,
62
63
  SOURCE_USES,
63
64
  } from "../vocabulary";
64
65
 
@@ -136,9 +137,14 @@ const VOCABULARIES = [
136
137
  members: ["pending", "approved", "rejected"],
137
138
  },
138
139
  {
139
- name: "SOURCE_SYNC_SCOPE_APPROVER_KINDS",
140
- tuple: SOURCE_SYNC_SCOPE_APPROVER_KINDS,
141
- members: ["org_admin", "unit_head"],
140
+ name: "SOURCE_SYNC_SCOPE_ASSIGNEE_BASES",
141
+ tuple: SOURCE_SYNC_SCOPE_ASSIGNEE_BASES,
142
+ members: ["lead", "delegate"],
143
+ },
144
+ {
145
+ name: "SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS",
146
+ tuple: SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS,
147
+ members: ["no_responsible_person", "no_eligible_approver"],
142
148
  },
143
149
  {
144
150
  name: "OPERATING_KNOWLEDGE_FAMILIES",
@@ -30,7 +30,8 @@ export type {
30
30
  SourceSyncDocRisk,
31
31
  SourceSyncDocDisposition,
32
32
  SourceSyncScopeApprovalState,
33
- SourceSyncScopeApproverKind,
33
+ SourceSyncScopeAssigneeBasis,
34
+ SourceSyncScopeUnassignedReason,
34
35
  SourceSyncScopeBlockedReason,
35
36
  DocumentType,
36
37
  KnownDocumentType,
@@ -47,7 +48,8 @@ export {
47
48
  SOURCE_SYNC_DOC_RISKS,
48
49
  SOURCE_SYNC_DOC_DISPOSITIONS,
49
50
  SOURCE_SYNC_SCOPE_APPROVAL_STATES,
50
- SOURCE_SYNC_SCOPE_APPROVER_KINDS,
51
+ SOURCE_SYNC_SCOPE_ASSIGNEE_BASES,
52
+ SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS,
51
53
  SOURCE_SYNC_SCOPE_BLOCKED_REASONS,
52
54
  DOCUMENT_TYPES,
53
55
  OPERATING_KNOWLEDGE_FAMILIES,
@@ -139,6 +141,8 @@ export type {
139
141
  SourceSyncAddCandidateRequest,
140
142
  SourceSyncApproveRequest,
141
143
  SourceSyncScopeApproval,
144
+ SourceSyncScopeAssignee,
145
+ SourceSyncScopeAssignment,
142
146
  SourceSyncScopeApprovalProjection,
143
147
  SourceSyncApproveResponse,
144
148
  SourceSyncRiskReason,
@@ -159,6 +163,8 @@ export {
159
163
  SourceSyncAddCandidateRequestSchema,
160
164
  SourceSyncApproveRequestSchema,
161
165
  SourceSyncScopeApprovalSchema,
166
+ SourceSyncScopeAssigneeSchema,
167
+ SourceSyncScopeAssignmentSchema,
162
168
  SourceSyncScopeApprovalProjectionSchema,
163
169
  SourceSyncApproveResponseSchema,
164
170
  SourceSyncDocChangeSchema,
@@ -25,8 +25,9 @@ import {
25
25
  SOURCE_SYNC_DOC_DISPOSITIONS,
26
26
  SOURCE_SYNC_DOC_RISKS,
27
27
  SOURCE_SYNC_SCOPE_APPROVAL_STATES,
28
- SOURCE_SYNC_SCOPE_APPROVER_KINDS,
28
+ SOURCE_SYNC_SCOPE_ASSIGNEE_BASES,
29
29
  SOURCE_SYNC_SCOPE_BLOCKED_REASONS,
30
+ SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS,
30
31
  } from "./vocabulary";
31
32
  import { CandidateDiscoverySummarySchema } from "./relevance";
32
33
  import { SourceSyncCandidateItemSchema } from "./candidate-item";
@@ -34,18 +35,45 @@ import { SourceSyncCandidateItemSchema } from "./candidate-item";
34
35
  /** A person as the review names them: a display label, never an id or an address. */
35
36
  const PersonLabel = z.string().min(1).max(255);
36
37
 
38
+ /** One person a scope's approval is assigned to, by label only. */
39
+ export const SourceSyncScopeAssigneeSchema = z.object({
40
+ label: PersonLabel,
41
+ /** True when this assignee is the caller. */
42
+ isCaller: z.boolean(),
43
+ basis: z.enum(SOURCE_SYNC_SCOPE_ASSIGNEE_BASES),
44
+ });
45
+
46
+ /**
47
+ * Whom a scope's approval is ASSIGNED to. Assignment is not eligibility: the
48
+ * caller's own `approvable` says whether THEY may approve; an account owner or an
49
+ * inherited leader may approve a scope assigned to someone else, or an
50
+ * unassigned one.
51
+ *
52
+ * - `assigned`: every eligible local lead and active delegate of the scope's unit
53
+ * (the root unit for the company scope), at least one, in the server's order.
54
+ * - `unassigned`: nobody, with the typed reason. There is no admin fallback.
55
+ *
56
+ * A pending scope reports its assignment as derived NOW. An approved scope reports
57
+ * the assignment its approval RECORDED; a later lead change never rewrites it.
58
+ */
59
+ export const SourceSyncScopeAssignmentSchema = z.discriminatedUnion("status", [
60
+ z.object({
61
+ status: z.literal("assigned"),
62
+ assignees: z.array(SourceSyncScopeAssigneeSchema).min(1).max(50),
63
+ }),
64
+ z.object({
65
+ status: z.literal("unassigned"),
66
+ reason: z.enum(SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS),
67
+ }),
68
+ ]);
69
+
37
70
  /**
38
71
  * ONE scope's approval as THE CALLER may see it. A response carries one per scope
39
72
  * the caller may see, and nothing about any other scope: not its key, not a count.
40
73
  *
41
- * - `requiredApproverKind` is who the scope needs: `org_admin` for the company
42
- * scope, `unit_head` for a unit's.
43
- * - `assignedTo` is the ONE person the scope is routed to, or null when it is
44
- * routed to the org's admins. Routing is not authority: any admin may approve.
45
- * - `vacantHead` is true exactly for a unit scope whose unit has no head. It is
46
- * still a `unit_head` scope, `assignedTo` is null, and the org's admins approve it.
47
- * - An approved scope reports the routing its approval RECORDED; a later head
48
- * change does not rewrite it. A pending scope reports its routing as of now.
74
+ * - `assignment` is whom the scope is assigned to (see
75
+ * SourceSyncScopeAssignmentSchema): the scope unit's local leads and active
76
+ * delegates, or nobody with a typed reason.
49
77
  * - `approvable` is whether THE CALLER may approve this pending scope now. A scope
50
78
  * can be visible but not approvable: approving needs approval authority on the
51
79
  * scope AND the caller's own upload and edit access on the scope's document,
@@ -61,15 +89,7 @@ export const SourceSyncScopeApprovalProjectionSchema = z.object({
61
89
  /** 'company' or 'unit:<uuid>'. */
62
90
  scopeKey: z.string().min(1).max(64),
63
91
  state: z.enum(SOURCE_SYNC_SCOPE_APPROVAL_STATES),
64
- requiredApproverKind: z.enum(SOURCE_SYNC_SCOPE_APPROVER_KINDS),
65
- assignedTo: z
66
- .object({
67
- label: PersonLabel,
68
- /** True when the scope is routed to the caller. */
69
- isCaller: z.boolean(),
70
- })
71
- .nullable(),
72
- vacantHead: z.boolean(),
92
+ assignment: SourceSyncScopeAssignmentSchema,
73
93
  /** Non-null exactly when a person's approval is recorded. */
74
94
  approvedBy: z.object({ label: PersonLabel }).nullable(),
75
95
  approvedAt: IsoDateTime.nullable(),
@@ -343,6 +363,12 @@ export type SourceSyncApproveRequest = z.infer<
343
363
  export type SourceSyncScopeApproval = z.infer<
344
364
  typeof SourceSyncScopeApprovalSchema
345
365
  >;
366
+ export type SourceSyncScopeAssignee = z.infer<
367
+ typeof SourceSyncScopeAssigneeSchema
368
+ >;
369
+ export type SourceSyncScopeAssignment = z.infer<
370
+ typeof SourceSyncScopeAssignmentSchema
371
+ >;
346
372
  export type SourceSyncScopeApprovalProjection = z.infer<
347
373
  typeof SourceSyncScopeApprovalProjectionSchema
348
374
  >;
@@ -101,15 +101,40 @@ export type SourceSyncScopeApprovalState =
101
101
  (typeof SOURCE_SYNC_SCOPE_APPROVAL_STATES)[number];
102
102
 
103
103
  /**
104
- * Who must approve a scope: the org's admins (the company scope) or the unit's
105
- * head (a unit scope). Mirrors the `required_approver_kind` CHECK.
104
+ * Why a person is ASSIGNED a scope's approval. Assignment is not authority: it
105
+ * names the people asked to decide, nothing more (control principle: authority to
106
+ * act is not assigned work).
107
+ *
108
+ * - `lead`: a LOCAL structural lead of the scope's unit. The company scope's unit
109
+ * is the org's root unit, so its leads are the root unit's local leads.
110
+ * - `delegate`: an ACTIVE (unexpired, unrevoked) delegate of that unit.
111
+ *
112
+ * Only local holders are ever assigned. A leader who holds authority over the
113
+ * unit by inheritance (an ancestor unit's lead) and an account owner may approve
114
+ * but are never assigned, so inherited authority never fans work out to every
115
+ * descendant team. Org admins are never assigned.
116
+ */
117
+ export const SOURCE_SYNC_SCOPE_ASSIGNEE_BASES = ["lead", "delegate"] as const;
118
+ export type SourceSyncScopeAssigneeBasis =
119
+ (typeof SOURCE_SYNC_SCOPE_ASSIGNEE_BASES)[number];
120
+
121
+ /**
122
+ * Why a scope has NOBODY assigned. Never a reason to fall back to the org's
123
+ * admins or the account owner: a vacancy grants no one anything, and an org
124
+ * admin resolves it by assigning a lead or a delegate (the backend's
125
+ * `sourceSync.approver_assignment_pending` action item).
126
+ *
127
+ * - `no_responsible_person`: the scope's unit has no local lead and no active
128
+ * delegate, or the unit no longer exists.
129
+ * - `no_eligible_approver`: it has local leads or delegates, but none of them can
130
+ * upload to and edit the scope's document (`needs_doc_access` for each).
106
131
  */
107
- export const SOURCE_SYNC_SCOPE_APPROVER_KINDS = [
108
- "org_admin",
109
- "unit_head",
132
+ export const SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS = [
133
+ "no_responsible_person",
134
+ "no_eligible_approver",
110
135
  ] as const;
111
- export type SourceSyncScopeApproverKind =
112
- (typeof SOURCE_SYNC_SCOPE_APPROVER_KINDS)[number];
136
+ export type SourceSyncScopeUnassignedReason =
137
+ (typeof SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS)[number];
113
138
 
114
139
  /**
115
140
  * Why the CALLER may not approve a scope they can see, or grant its standing