@company-semantics/contracts 101.0.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 +1 -1
- package/src/__tests__/leader-access.test.ts +51 -0
- package/src/action-items/README.md +9 -8
- package/src/action-items/__tests__/approver-needed-kind.test.ts +42 -0
- package/src/action-items/kinds.ts +14 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +64 -32
- package/src/permissions/share-api.ts +39 -0
- package/src/proactive/kinds.ts +7 -0
- package/src/schedules/README.md +9 -0
- package/src/schedules/__tests__/api.test.ts +51 -0
- package/src/schedules/__tests__/schedules-exports.test.ts +3 -0
- package/src/schedules/api.ts +73 -0
- package/src/schedules/index.ts +7 -0
- package/src/source-sync/README.md +22 -6
- package/src/source-sync/__tests__/scope-assignment.test.ts +96 -0
- package/src/source-sync/__tests__/scope-projection.test.ts +56 -18
- package/src/source-sync/__tests__/vocabulary.test.ts +11 -5
- package/src/source-sync/index.ts +8 -2
- package/src/source-sync/schemas.ts +44 -18
- package/src/source-sync/vocabulary.ts +32 -7
package/package.json
CHANGED
|
@@ -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
|
|
71
|
-
|
|
|
72
|
-
| `companyMd.access_request_pending`
|
|
73
|
-
| `execution.confirmation_pending`
|
|
74
|
-
| `structure.owner_grant_pending`
|
|
75
|
-
| `structure.finding_review_pending`
|
|
76
|
-
| `sourceSync.approval_pending`
|
|
77
|
-
| `sourceSync.proposal_pending`
|
|
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 = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '1edc559559e5' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '1edc559559e5cd64ba5b3d4d3c7a61822aa00f0d63ae30d297c970f3cdf5103e' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -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
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
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
|
-
|
|
10910
|
-
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
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
|
-
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
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
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
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`.
|
package/src/proactive/kinds.ts
CHANGED
|
@@ -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];
|
package/src/schedules/README.md
CHANGED
|
@@ -23,6 +23,8 @@ ledger and reconciles) can never disagree about a slot.
|
|
|
23
23
|
`contentHash` is computed over.
|
|
24
24
|
- Response schemas only; request bodies live in the backend beside their routes
|
|
25
25
|
(ScheduleTickRequest is a type, not a schema).
|
|
26
|
+
- A re-prove grants exactly one extra attempt for a named execution; the route
|
|
27
|
+
only grants, the backend clock dispatches.
|
|
26
28
|
- Pure: no clock reads, no I/O, only `zod`.
|
|
27
29
|
|
|
28
30
|
## Dependencies
|
|
@@ -64,6 +66,9 @@ ledger and reconciles) can never disagree about a slot.
|
|
|
64
66
|
- `SCHEDULE_MODES` — off: GitHub cron only, clock ignores it. shadow: clock plans, never dispatches. live: clock dispatches…
|
|
65
67
|
- `SCHEDULE_OUTPUT_VERIFIERS` — Closed catalog. v0 has one verifier.
|
|
66
68
|
- `SCHEDULE_REGISTRY_SCHEMA_VERSION`
|
|
69
|
+
- `SCHEDULE_REPROVE_PATH` — The operator re-prove route (control ADR slug operator-schedule-reprove).
|
|
70
|
+
- `SCHEDULE_REPROVE_REASON_MAX_LENGTH` — Upper bound of a re-prove reason (operator prose, kept for audit only).
|
|
71
|
+
- `SCHEDULE_REPROVE_REFUSALS` — Why a re-prove was refused.
|
|
67
72
|
- `SCHEDULE_RUN_CONCLUSIONS`
|
|
68
73
|
- `SCHEDULE_RUN_NAME` — The exact `run-name:` a registered workflow declares, so displaytitle = executionid#attempt.
|
|
69
74
|
- `SCHEDULE_SEVERITIES`
|
|
@@ -91,6 +96,10 @@ ledger and reconciles) can never disagree about a slot.
|
|
|
91
96
|
- `ScheduleOutputVerifier` _(type)_
|
|
92
97
|
- `ScheduleRegistrySnapshot` _(type)_
|
|
93
98
|
- `ScheduleRegistrySnapshotSchema`
|
|
99
|
+
- `ScheduleReproveRefusal` _(type)_
|
|
100
|
+
- `ScheduleReproveRequest` _(type)_ — Re-prove request body, as a TYPE only: the backend binds its zod schema to it with `satisfies…
|
|
101
|
+
- `ScheduleReproveResponse` _(type)_
|
|
102
|
+
- `ScheduleReproveResponseSchema` — `granted`: the clock will dispatch `<executionId>#<grantedAttempt>` on its next pass.
|
|
94
103
|
- `ScheduleRunConclusion` _(type)_
|
|
95
104
|
- `ScheduleSeverity` _(type)_
|
|
96
105
|
- `ScheduleTickRequest` _(type)_ — Tick request body, as a TYPE only: request schemas stay in the backend beside the route (contracts…
|
|
@@ -5,9 +5,12 @@ import {
|
|
|
5
5
|
SCHEDULE_METRIC_TICK,
|
|
6
6
|
SCHEDULE_METRIC_TICK_SOURCE_DIMENSION,
|
|
7
7
|
SCHEDULE_METRIC_ENVIRONMENT_DIMENSION,
|
|
8
|
+
SCHEDULE_REPROVE_PATH,
|
|
9
|
+
SCHEDULE_REPROVE_REFUSALS,
|
|
8
10
|
SCHEDULE_TICK_PATH,
|
|
9
11
|
ScheduleClaimResponseSchema,
|
|
10
12
|
ScheduleFinishResponseSchema,
|
|
13
|
+
ScheduleReproveResponseSchema,
|
|
11
14
|
ScheduleTickResponseSchema,
|
|
12
15
|
} from "../api";
|
|
13
16
|
|
|
@@ -69,4 +72,52 @@ describe("schedules api responses", () => {
|
|
|
69
72
|
"environment",
|
|
70
73
|
]);
|
|
71
74
|
});
|
|
75
|
+
|
|
76
|
+
it("grants a re-prove for a named attempt", () => {
|
|
77
|
+
expect(SCHEDULE_REPROVE_PATH).toBe("/api/schedules/executions/reprove");
|
|
78
|
+
expect(
|
|
79
|
+
ScheduleReproveResponseSchema.safeParse({
|
|
80
|
+
outcome: "granted",
|
|
81
|
+
executionId: "control-pin-audit/2026-10-12T15:00:00Z",
|
|
82
|
+
grantedAttempt: 4,
|
|
83
|
+
incidentKey:
|
|
84
|
+
"schedule:execution:control-pin-audit/2026-10-12T15:00:00Z",
|
|
85
|
+
incidentNoted: true,
|
|
86
|
+
}).success,
|
|
87
|
+
).toBe(true);
|
|
88
|
+
expect(
|
|
89
|
+
ScheduleReproveResponseSchema.safeParse({
|
|
90
|
+
outcome: "granted",
|
|
91
|
+
executionId: "control-pin-audit/2026-10-12T15:00:00Z",
|
|
92
|
+
grantedAttempt: 1,
|
|
93
|
+
incidentKey: null,
|
|
94
|
+
incidentNoted: false,
|
|
95
|
+
}).success,
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses a re-prove with a closed reason", () => {
|
|
100
|
+
expect(SCHEDULE_REPROVE_REFUSALS).toEqual([
|
|
101
|
+
"unknown_execution",
|
|
102
|
+
"schedule_not_live",
|
|
103
|
+
"not_exhausted",
|
|
104
|
+
"not_reprovable",
|
|
105
|
+
"schedule_busy",
|
|
106
|
+
"grant_lost",
|
|
107
|
+
]);
|
|
108
|
+
expect(
|
|
109
|
+
ScheduleReproveResponseSchema.safeParse({
|
|
110
|
+
outcome: "refused",
|
|
111
|
+
executionId: "x/2026-10-12T15:00:00Z",
|
|
112
|
+
refusal: "not_exhausted",
|
|
113
|
+
}).success,
|
|
114
|
+
).toBe(true);
|
|
115
|
+
expect(
|
|
116
|
+
ScheduleReproveResponseSchema.safeParse({
|
|
117
|
+
outcome: "refused",
|
|
118
|
+
executionId: "x/2026-10-12T15:00:00Z",
|
|
119
|
+
refusal: "because",
|
|
120
|
+
}).success,
|
|
121
|
+
).toBe(false);
|
|
122
|
+
});
|
|
72
123
|
});
|
|
@@ -19,6 +19,9 @@ describe("schedules exports", () => {
|
|
|
19
19
|
"ScheduleClaimResponseSchema",
|
|
20
20
|
"SCHEDULE_TICK_PATH",
|
|
21
21
|
"SCHEDULE_METRICS_NAMESPACE",
|
|
22
|
+
"SCHEDULE_REPROVE_PATH",
|
|
23
|
+
"SCHEDULE_REPROVE_REFUSALS",
|
|
24
|
+
"ScheduleReproveResponseSchema",
|
|
22
25
|
]) {
|
|
23
26
|
expect(root).toHaveProperty(name);
|
|
24
27
|
}
|
package/src/schedules/api.ts
CHANGED
|
@@ -137,3 +137,76 @@ export const ScheduleTickResponseSchema = z
|
|
|
137
137
|
})
|
|
138
138
|
.strict();
|
|
139
139
|
export type ScheduleTickResponse = z.infer<typeof ScheduleTickResponseSchema>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The operator re-prove route (control ADR slug operator-schedule-reprove).
|
|
143
|
+
* Its only caller is control's schedules-reprove.yml on main, authenticated by
|
|
144
|
+
* that job's GitHub OIDC token; an operator starts the workflow with
|
|
145
|
+
* `pnpm schedules:reprove <scheduleId> <slot> --reason "…"`. The route only
|
|
146
|
+
* GRANTS one extra attempt for a named execution; the backend clock dispatches
|
|
147
|
+
* it through the normal claim-before-call path.
|
|
148
|
+
*/
|
|
149
|
+
export const SCHEDULE_REPROVE_PATH = "/api/schedules/executions/reprove";
|
|
150
|
+
|
|
151
|
+
/** Upper bound of a re-prove reason (operator prose, kept for audit only). */
|
|
152
|
+
export const SCHEDULE_REPROVE_REASON_MAX_LENGTH = 500;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Re-prove request body, as a TYPE only: the backend binds its zod schema to it
|
|
156
|
+
* with `satisfies z.ZodType<ScheduleReproveRequest>`. `executionId` names the
|
|
157
|
+
* exhausted slot (`<schedule_id>/<slot>`); `reason` (1..500 chars) says why the
|
|
158
|
+
* operator believes the cause is fixed.
|
|
159
|
+
*/
|
|
160
|
+
export interface ScheduleReproveRequest {
|
|
161
|
+
readonly executionId: string;
|
|
162
|
+
readonly reason: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Why a re-prove was refused. Closed.
|
|
167
|
+
* - unknown_execution: no ledger row for that execution id.
|
|
168
|
+
* - schedule_not_live: not in the active registry, or its mode is not live.
|
|
169
|
+
* - not_exhausted: the clock still owns the slot (attempts remain and effects
|
|
170
|
+
* allow re-execution, a grant is unspent, or admission has not timed out).
|
|
171
|
+
* - not_reprovable: succeeded, settled, due, started, or failed only its
|
|
172
|
+
* output verification.
|
|
173
|
+
* - schedule_busy: another execution of the same schedule is in flight.
|
|
174
|
+
* - grant_lost: a concurrent grant or dispatch changed the row first.
|
|
175
|
+
*/
|
|
176
|
+
export const SCHEDULE_REPROVE_REFUSALS = [
|
|
177
|
+
"unknown_execution",
|
|
178
|
+
"schedule_not_live",
|
|
179
|
+
"not_exhausted",
|
|
180
|
+
"not_reprovable",
|
|
181
|
+
"schedule_busy",
|
|
182
|
+
"grant_lost",
|
|
183
|
+
] as const;
|
|
184
|
+
export type ScheduleReproveRefusal = (typeof SCHEDULE_REPROVE_REFUSALS)[number];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `granted`: the clock will dispatch `<executionId>#<grantedAttempt>` on its
|
|
188
|
+
* next pass. `incidentKey` is the open execution/admission incident naming the
|
|
189
|
+
* slot (null when none); `incidentNoted` says whether the grant was commented
|
|
190
|
+
* on its issue (a failed comment never revokes the grant).
|
|
191
|
+
*/
|
|
192
|
+
export const ScheduleReproveResponseSchema = z.discriminatedUnion("outcome", [
|
|
193
|
+
z
|
|
194
|
+
.object({
|
|
195
|
+
outcome: z.literal("granted"),
|
|
196
|
+
executionId: z.string(),
|
|
197
|
+
grantedAttempt: z.number().int().min(2),
|
|
198
|
+
incidentKey: z.string().nullable(),
|
|
199
|
+
incidentNoted: z.boolean(),
|
|
200
|
+
})
|
|
201
|
+
.strict(),
|
|
202
|
+
z
|
|
203
|
+
.object({
|
|
204
|
+
outcome: z.literal("refused"),
|
|
205
|
+
executionId: z.string(),
|
|
206
|
+
refusal: z.enum(SCHEDULE_REPROVE_REFUSALS),
|
|
207
|
+
})
|
|
208
|
+
.strict(),
|
|
209
|
+
]);
|
|
210
|
+
export type ScheduleReproveResponse = z.infer<
|
|
211
|
+
typeof ScheduleReproveResponseSchema
|
|
212
|
+
>;
|
package/src/schedules/index.ts
CHANGED
|
@@ -108,6 +108,10 @@ export {
|
|
|
108
108
|
ScheduleFinishResponseSchema,
|
|
109
109
|
ScheduleTickResponseSchema,
|
|
110
110
|
SCHEDULE_TICK_PATH,
|
|
111
|
+
SCHEDULE_REPROVE_PATH,
|
|
112
|
+
SCHEDULE_REPROVE_REASON_MAX_LENGTH,
|
|
113
|
+
SCHEDULE_REPROVE_REFUSALS,
|
|
114
|
+
ScheduleReproveResponseSchema,
|
|
111
115
|
SCHEDULE_METRICS_NAMESPACE,
|
|
112
116
|
SCHEDULE_METRIC_TICK,
|
|
113
117
|
SCHEDULE_METRIC_TICK_SOURCE_DIMENSION,
|
|
@@ -121,4 +125,7 @@ export type {
|
|
|
121
125
|
ScheduleFinishResponse,
|
|
122
126
|
ScheduleTickResponse,
|
|
123
127
|
ScheduleTickRequest,
|
|
128
|
+
ScheduleReproveRequest,
|
|
129
|
+
ScheduleReproveRefusal,
|
|
130
|
+
ScheduleReproveResponse,
|
|
124
131
|
} from "./api";
|
|
@@ -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.
|
|
54
|
-
|
|
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
|
-
- `
|
|
283
|
-
`
|
|
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,
|
|
343
|
-
|
|
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
|
|
6
|
-
* are
|
|
7
|
-
* - A
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
53
|
+
it("accepts a unit scope with no lead or delegate: unassigned, no_responsible_person", () => {
|
|
51
54
|
const parsed = SourceSyncScopeApprovalProjectionSchema.safeParse(
|
|
52
|
-
scope({
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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({
|
|
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
|
|
124
|
+
it("refuses an empty assignee label and a missing assignment", () => {
|
|
93
125
|
expect(
|
|
94
126
|
SourceSyncScopeApprovalProjectionSchema.safeParse(
|
|
95
|
-
scope({
|
|
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 {
|
|
135
|
+
const { assignment: _omit, ...withoutAssignment } = scope();
|
|
99
136
|
expect(
|
|
100
|
-
SourceSyncScopeApprovalProjectionSchema.safeParse(
|
|
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-
|
|
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
|
-
|
|
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: "
|
|
140
|
-
tuple:
|
|
141
|
-
members: ["
|
|
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",
|
package/src/source-sync/index.ts
CHANGED
|
@@ -30,7 +30,8 @@ export type {
|
|
|
30
30
|
SourceSyncDocRisk,
|
|
31
31
|
SourceSyncDocDisposition,
|
|
32
32
|
SourceSyncScopeApprovalState,
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* - `
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
105
|
-
*
|
|
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
|
|
108
|
-
"
|
|
109
|
-
"
|
|
132
|
+
export const SOURCE_SYNC_SCOPE_UNASSIGNED_REASONS = [
|
|
133
|
+
"no_responsible_person",
|
|
134
|
+
"no_eligible_approver",
|
|
110
135
|
] as const;
|
|
111
|
-
export type
|
|
112
|
-
(typeof
|
|
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
|