@brainervirus/workit-core 0.11.0 → 1.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/README.md +5 -3
- package/package.json +8 -3
- package/scripts/analyze-release-scope.ts +4 -1
- package/scripts/doctor-check.ts +3 -1
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +1 -0
- package/scripts/install-opencode-plugin.sh +1 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/rewrite-workspace-deps.ts +22 -5
- package/scripts/sync-release-manifests.ts +8 -5
- package/scripts/sync-runtime.sh +7 -3
- package/scripts/validate-cursor-marketplace.ts +16 -62
- package/skills/workit-babysit/SKILL.md +33 -0
- package/skills/workit-behavioral-tdd/SKILL.md +53 -0
- package/skills/workit-blast-radius/SKILL.md +31 -0
- package/skills/workit-challenge/SKILL.md +62 -0
- package/skills/workit-debug/SKILL.md +61 -0
- package/skills/workit-deslop/SKILL.md +36 -0
- package/skills/workit-diagram/SKILL.md +32 -0
- package/skills/workit-green-run/SKILL.md +29 -0
- package/skills/workit-handoff/SKILL.md +43 -0
- package/skills/workit-implement/SKILL.md +46 -0
- package/skills/workit-mockup/SKILL.md +28 -0
- package/skills/workit-plan/SKILL.md +66 -0
- package/skills/workit-review/SKILL.md +60 -0
- package/skills/workit-steer/SKILL.md +32 -0
- package/src/core/authority.ts +918 -0
- package/src/core/boundary.ts +29 -0
- package/src/core/branch.ts +26 -255
- package/src/core/changelog.ts +95 -18
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-conversion.ts +183 -0
- package/src/core/config.ts +42 -0
- package/src/core/cutover.ts +721 -0
- package/src/core/detect-hosts.ts +107 -0
- package/src/core/doctor.ts +428 -18
- package/src/core/external-action-effects.ts +1171 -0
- package/src/core/external-action.ts +541 -0
- package/src/core/init.ts +83 -32
- package/src/core/methods.ts +125 -0
- package/src/core/policy-resolver.ts +523 -0
- package/src/core/pr-create.ts +7 -1
- package/src/core/registration.ts +69 -4
- package/src/core/repo-context.ts +59 -1
- package/src/core/setup.ts +28 -0
- package/src/core/skill-manifests.ts +40 -42
- package/src/core/support-matrix.ts +8 -3
- package/src/core/sync-runtime.ts +15 -3
- package/src/core/task-context.ts +179 -0
- package/src/core/task-contract.ts +1136 -0
- package/src/core/task-engine.ts +2562 -0
- package/src/core/task-evaluation.ts +679 -0
- package/src/core/task-store.ts +1071 -0
- package/src/core/tracker-issues.ts +237 -0
- package/src/core/uninstall.ts +67 -3
- package/src/core/vcs-config.ts +53 -22
- package/src/core/workers.ts +166 -0
- package/src/core/workspaces.ts +3 -1
- package/src/core/youtrack-tools.ts +2 -0
- package/src/core/youtrack.ts +145 -10
- package/src/core.ts +126 -0
- package/templates/execution-contract.md +17 -50
- package/templates/plan-template.md +3 -3
- package/templates/spec-template.md +15 -3
- package/templates/workit-contract.md +12 -0
- package/commands/wk-changelog.md +0 -2
- package/commands/wk-commit.md +0 -2
- package/commands/wk-docs-refresh.md +0 -2
- package/commands/wk-handoff.md +0 -2
- package/commands/wk-implement.md +0 -2
- package/commands/wk-init.md +0 -2
- package/commands/wk-issue-update.md +0 -2
- package/commands/wk-meetings.md +0 -2
- package/commands/wk-pr.md +0 -2
- package/commands/wk-release-notes.md +0 -2
- package/commands/wk-status.md +0 -2
- package/commands/wk-verify.md +0 -2
- package/scripts/update-superpowers.sh +0 -82
- package/scripts/vendor-assets.ts +0 -37
- package/skills/wk-changelog/SKILL.md +0 -15
- package/skills/wk-commit/SKILL.md +0 -16
- package/skills/wk-docs-refresh/SKILL.md +0 -15
- package/skills/wk-handoff/SKILL.md +0 -20
- package/skills/wk-implement/SKILL.md +0 -47
- package/skills/wk-init/SKILL.md +0 -31
- package/skills/wk-issue-update/SKILL.md +0 -27
- package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
- package/skills/wk-meetings/SKILL.md +0 -17
- package/skills/wk-pr/SKILL.md +0 -27
- package/skills/wk-release-notes/SKILL.md +0 -15
- package/skills/wk-status/SKILL.md +0 -16
- package/skills/wk-verify/SKILL.md +0 -16
- package/src/core/detector.ts +0 -239
- package/src/core/flow-state.ts +0 -3308
- package/src/core/handoff-context.ts +0 -136
- package/src/core/handoff-tools.ts +0 -133
- package/src/core/menu.ts +0 -70
- package/src/core/plan-tasks.ts +0 -43
- package/src/core/reminder.ts +0 -124
- package/src/core/sdd.ts +0 -353
- package/src/state.ts +0 -22
- package/templates/superpowers-doc-contract.md +0 -75
- package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
- package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
- package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
- package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
- package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
- package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
- package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
- package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
- package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
- package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
- package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
- package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
- package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -428
- package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
- package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
- package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
- package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
- package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
- package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
- package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
- package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
- package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
- package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
- package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
- package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
- package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
- package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
- package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
- package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
- package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
- package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
- package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
- package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
- package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
- package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
- package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
- package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
- package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
- package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
canonicalJson,
|
|
6
|
+
decisionDigest,
|
|
7
|
+
failure,
|
|
8
|
+
provenanceSchema,
|
|
9
|
+
refSchema,
|
|
10
|
+
scopeCovers,
|
|
11
|
+
success,
|
|
12
|
+
type Decision,
|
|
13
|
+
type Entry,
|
|
14
|
+
type Caller,
|
|
15
|
+
type Digest,
|
|
16
|
+
type Id,
|
|
17
|
+
type Provenance,
|
|
18
|
+
type Ref,
|
|
19
|
+
type Result,
|
|
20
|
+
type Revision,
|
|
21
|
+
type TaskRecord,
|
|
22
|
+
type Utc,
|
|
23
|
+
} from "./task-contract";
|
|
24
|
+
import { TaskStore } from "./task-store";
|
|
25
|
+
|
|
26
|
+
export type NativeAuthorityContext = {
|
|
27
|
+
now: Utc;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NativeDecisionVerification = {
|
|
31
|
+
observation: unknown;
|
|
32
|
+
expected: {
|
|
33
|
+
taskId: Id;
|
|
34
|
+
workspaceId: Id;
|
|
35
|
+
purpose: Decision["purpose"];
|
|
36
|
+
response: Decision["response"];
|
|
37
|
+
binding: Decision["binding"];
|
|
38
|
+
bindingBytes: string;
|
|
39
|
+
digest: Digest;
|
|
40
|
+
requirementIds: Digest[];
|
|
41
|
+
};
|
|
42
|
+
caller: Caller;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type NativeActionVerification = {
|
|
46
|
+
observation: unknown;
|
|
47
|
+
expected: {
|
|
48
|
+
taskId: Id;
|
|
49
|
+
workspaceId: Id;
|
|
50
|
+
decisionId: Id;
|
|
51
|
+
actionRef: Ref;
|
|
52
|
+
taskRevision: Revision;
|
|
53
|
+
workspaceRevision: Revision;
|
|
54
|
+
outcome: "reserve" | "succeeded" | "not_started" | "unknown";
|
|
55
|
+
decision: Pick<Decision, "purpose" | "response" | "binding" | "digest" | "requirementIds">;
|
|
56
|
+
};
|
|
57
|
+
caller: Caller;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type NativeReconciliationVerification = {
|
|
61
|
+
observation: unknown;
|
|
62
|
+
expected: {
|
|
63
|
+
taskId: Id;
|
|
64
|
+
workspaceId: Id;
|
|
65
|
+
decisionId: Id;
|
|
66
|
+
actionRef: Ref;
|
|
67
|
+
taskRevision: Revision;
|
|
68
|
+
workspaceRevision: Revision;
|
|
69
|
+
outcome: "succeeded" | "not_started";
|
|
70
|
+
evidenceDigest: Digest;
|
|
71
|
+
step?: string;
|
|
72
|
+
decision: Pick<Decision, "purpose" | "response" | "binding" | "digest" | "requirementIds">;
|
|
73
|
+
};
|
|
74
|
+
caller: Caller;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type NativeAuthorityVerifier = {
|
|
78
|
+
verifyDecision: (input: NativeDecisionVerification) => Result<Provenance>;
|
|
79
|
+
verifyAction: (input: NativeActionVerification) => Result<Provenance>;
|
|
80
|
+
verifyReconciliation?: (input: NativeReconciliationVerification) => Result<Provenance>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type VerifiedNativeAuthority = object;
|
|
84
|
+
|
|
85
|
+
type AuthorityRecord = {
|
|
86
|
+
kind: "decision" | "action" | "reconciliation";
|
|
87
|
+
provenance: Provenance;
|
|
88
|
+
taskId: Id;
|
|
89
|
+
workspaceId: Id;
|
|
90
|
+
decisionId?: Id;
|
|
91
|
+
purpose?: Decision["purpose"];
|
|
92
|
+
response?: Decision["response"];
|
|
93
|
+
binding?: Decision["binding"];
|
|
94
|
+
bindingBytes?: string;
|
|
95
|
+
digest?: Digest;
|
|
96
|
+
requirementIds?: Digest[];
|
|
97
|
+
actionRef?: Ref;
|
|
98
|
+
taskRevision?: Revision;
|
|
99
|
+
workspaceRevision?: Revision;
|
|
100
|
+
outcome?: "reserve" | "succeeded" | "not_started" | "unknown";
|
|
101
|
+
evidenceDigest?: Digest;
|
|
102
|
+
step?: string;
|
|
103
|
+
owner: object;
|
|
104
|
+
store: TaskStore;
|
|
105
|
+
root: string;
|
|
106
|
+
caller: Caller;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type AuthorityBinding = { owner: object; store: TaskStore; root: string };
|
|
110
|
+
|
|
111
|
+
const verifiedAuthorities = new WeakMap<object, AuthorityRecord>();
|
|
112
|
+
|
|
113
|
+
const trustedAuthority = (
|
|
114
|
+
kind: AuthorityRecord["kind"],
|
|
115
|
+
provenance: Provenance,
|
|
116
|
+
expected: Omit<AuthorityRecord, "kind" | "provenance" | "owner" | "store" | "root" | "caller">,
|
|
117
|
+
binding: AuthorityBinding,
|
|
118
|
+
caller: Caller,
|
|
119
|
+
): VerifiedNativeAuthority => {
|
|
120
|
+
const token = {};
|
|
121
|
+
verifiedAuthorities.set(token, { kind, provenance, ...expected, ...binding, caller });
|
|
122
|
+
return token;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type ReserveActionInput = {
|
|
126
|
+
store: TaskStore;
|
|
127
|
+
taskId: Id;
|
|
128
|
+
decisionId: Id;
|
|
129
|
+
actionRef: Ref;
|
|
130
|
+
expectedRevision: Revision;
|
|
131
|
+
expectedWorkspaceRevision: Revision;
|
|
132
|
+
binding?: Decision["binding"];
|
|
133
|
+
step?: string;
|
|
134
|
+
steps?: string[];
|
|
135
|
+
authority: VerifiedNativeAuthority;
|
|
136
|
+
authorityOwner: object;
|
|
137
|
+
authorityCaller: Caller;
|
|
138
|
+
native?: NativeAuthorityContext;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type ActionReservation = {
|
|
142
|
+
taskId: Id;
|
|
143
|
+
decisionId: Id;
|
|
144
|
+
actionRef: Ref;
|
|
145
|
+
taskRevision: Revision;
|
|
146
|
+
workspaceRevision: Revision;
|
|
147
|
+
completedSteps: string[];
|
|
148
|
+
remainingSteps: string[];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type SettleActionInput = {
|
|
152
|
+
store: TaskStore;
|
|
153
|
+
taskId: Id;
|
|
154
|
+
decisionId: Id;
|
|
155
|
+
actionRef: Ref;
|
|
156
|
+
taskRevision: Revision;
|
|
157
|
+
workspaceRevision: Revision;
|
|
158
|
+
outcome: "succeeded" | "not_started" | "unknown";
|
|
159
|
+
step?: string;
|
|
160
|
+
authority: VerifiedNativeAuthority;
|
|
161
|
+
authorityOwner: object;
|
|
162
|
+
authorityCaller: Caller;
|
|
163
|
+
native?: NativeAuthorityContext;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type ReconcileActionInput = {
|
|
167
|
+
store: TaskStore;
|
|
168
|
+
taskId: Id;
|
|
169
|
+
decisionId: Id;
|
|
170
|
+
actionRef: Ref;
|
|
171
|
+
taskRevision: Revision;
|
|
172
|
+
workspaceRevision: Revision;
|
|
173
|
+
outcome: "succeeded" | "not_started";
|
|
174
|
+
evidenceDigest: Digest;
|
|
175
|
+
step?: string;
|
|
176
|
+
authority: VerifiedNativeAuthority;
|
|
177
|
+
authorityOwner: object;
|
|
178
|
+
authorityCaller: Caller;
|
|
179
|
+
native?: NativeAuthorityContext;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type Workflow = { steps: string[]; completed: string[] };
|
|
183
|
+
|
|
184
|
+
const scopeEqual = (
|
|
185
|
+
left: Decision["binding"]["scope"],
|
|
186
|
+
right: Decision["binding"]["scope"],
|
|
187
|
+
): boolean => canonicalJson(left) === canonicalJson(right);
|
|
188
|
+
|
|
189
|
+
const parseSteps = (content: string): string[] | null => {
|
|
190
|
+
let value: unknown;
|
|
191
|
+
try {
|
|
192
|
+
value = JSON.parse(content) as unknown;
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
if (typeof value !== "object" || value === null || !("steps" in value)) return [];
|
|
197
|
+
const steps = (value as { steps?: unknown }).steps;
|
|
198
|
+
return Array.isArray(steps) && steps.every((step) => typeof step === "string") ? steps : null;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const decisionMatches = (
|
|
202
|
+
entry: Entry<Decision>,
|
|
203
|
+
purpose: Decision["purpose"],
|
|
204
|
+
binding: Decision["binding"],
|
|
205
|
+
): boolean => {
|
|
206
|
+
const decision = entry.data;
|
|
207
|
+
return (
|
|
208
|
+
binding.taskId === decision.binding.taskId &&
|
|
209
|
+
binding.workspaceId === decision.binding.workspaceId &&
|
|
210
|
+
decision.purpose === purpose &&
|
|
211
|
+
decision.response === "approved" &&
|
|
212
|
+
decision.revoked === null &&
|
|
213
|
+
(purpose !== "action" || decision.consumption === null) &&
|
|
214
|
+
decision.digest === decisionDigest(decision) &&
|
|
215
|
+
scopeEqual(decision.binding.scope, binding.scope) &&
|
|
216
|
+
canonicalJson(decision.binding) === canonicalJson(binding)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const sameRef = (left: Ref, right: Ref): boolean => canonicalJson(left) === canonicalJson(right);
|
|
221
|
+
|
|
222
|
+
const validProvenance = (value: unknown, caller: Caller): value is Provenance => {
|
|
223
|
+
if (!provenanceSchema.safeParse(value).success) return false;
|
|
224
|
+
const provenance = value as Provenance;
|
|
225
|
+
return (
|
|
226
|
+
provenance.kind === "host_observed" &&
|
|
227
|
+
provenance.host === caller.host &&
|
|
228
|
+
provenance.session?.kind === "host" &&
|
|
229
|
+
provenance.session.host === caller.host &&
|
|
230
|
+
provenance.session.handle === caller.actor &&
|
|
231
|
+
provenance.receipts.some((receipt) => receipt.kind === "host" && receipt.host === caller.host)
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const verifyNativeDecision = (
|
|
236
|
+
verifier: NativeAuthorityVerifier | undefined,
|
|
237
|
+
input: NativeDecisionVerification,
|
|
238
|
+
binding: AuthorityBinding,
|
|
239
|
+
): Result<VerifiedNativeAuthority> => {
|
|
240
|
+
if (!verifier) return failure("permission_denied", "native authority verifier is unavailable");
|
|
241
|
+
const result = verifier.verifyDecision(input);
|
|
242
|
+
if (!result.ok || !validProvenance(result.data, input.caller))
|
|
243
|
+
return failure("permission_denied", "native decision observation was not attested");
|
|
244
|
+
return success(
|
|
245
|
+
null,
|
|
246
|
+
null,
|
|
247
|
+
trustedAuthority(
|
|
248
|
+
"decision",
|
|
249
|
+
result.data,
|
|
250
|
+
{
|
|
251
|
+
taskId: input.expected.taskId,
|
|
252
|
+
workspaceId: input.expected.workspaceId,
|
|
253
|
+
purpose: input.expected.purpose,
|
|
254
|
+
response: input.expected.response,
|
|
255
|
+
binding: input.expected.binding,
|
|
256
|
+
bindingBytes: input.expected.bindingBytes,
|
|
257
|
+
digest: input.expected.digest,
|
|
258
|
+
requirementIds: [...input.expected.requirementIds],
|
|
259
|
+
},
|
|
260
|
+
binding,
|
|
261
|
+
input.caller,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const verifyNativeAction = (
|
|
267
|
+
verifier: NativeAuthorityVerifier | undefined,
|
|
268
|
+
input: NativeActionVerification,
|
|
269
|
+
binding: AuthorityBinding,
|
|
270
|
+
): Result<VerifiedNativeAuthority> => {
|
|
271
|
+
if (!verifier) return failure("permission_denied", "native authority verifier is unavailable");
|
|
272
|
+
const result = verifier.verifyAction(input);
|
|
273
|
+
if (!result.ok || !validProvenance(result.data, input.caller))
|
|
274
|
+
return failure("permission_denied", "native action observation was not attested");
|
|
275
|
+
return success(
|
|
276
|
+
null,
|
|
277
|
+
null,
|
|
278
|
+
trustedAuthority(
|
|
279
|
+
"action",
|
|
280
|
+
result.data,
|
|
281
|
+
{
|
|
282
|
+
taskId: input.expected.taskId,
|
|
283
|
+
workspaceId: input.expected.workspaceId,
|
|
284
|
+
decisionId: input.expected.decisionId,
|
|
285
|
+
actionRef: input.expected.actionRef,
|
|
286
|
+
taskRevision: input.expected.taskRevision,
|
|
287
|
+
workspaceRevision: input.expected.workspaceRevision,
|
|
288
|
+
outcome: input.expected.outcome,
|
|
289
|
+
purpose: input.expected.decision.purpose,
|
|
290
|
+
response: input.expected.decision.response,
|
|
291
|
+
binding: input.expected.decision.binding,
|
|
292
|
+
digest: input.expected.decision.digest,
|
|
293
|
+
requirementIds: [...input.expected.decision.requirementIds],
|
|
294
|
+
},
|
|
295
|
+
binding,
|
|
296
|
+
input.caller,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const verifyNativeReconciliation = (
|
|
302
|
+
verifier: NativeAuthorityVerifier | undefined,
|
|
303
|
+
input: NativeReconciliationVerification,
|
|
304
|
+
binding: AuthorityBinding,
|
|
305
|
+
): Result<VerifiedNativeAuthority> => {
|
|
306
|
+
if (!verifier?.verifyReconciliation)
|
|
307
|
+
return failure("permission_denied", "native reconciliation authority is unavailable");
|
|
308
|
+
const result = verifier.verifyReconciliation(input);
|
|
309
|
+
if (!result.ok || !validProvenance(result.data, input.caller))
|
|
310
|
+
return failure("permission_denied", "native reconciliation observation was not attested");
|
|
311
|
+
return success(
|
|
312
|
+
null,
|
|
313
|
+
null,
|
|
314
|
+
trustedAuthority(
|
|
315
|
+
"reconciliation",
|
|
316
|
+
result.data,
|
|
317
|
+
{
|
|
318
|
+
taskId: input.expected.taskId,
|
|
319
|
+
workspaceId: input.expected.workspaceId,
|
|
320
|
+
decisionId: input.expected.decisionId,
|
|
321
|
+
actionRef: input.expected.actionRef,
|
|
322
|
+
taskRevision: input.expected.taskRevision,
|
|
323
|
+
workspaceRevision: input.expected.workspaceRevision,
|
|
324
|
+
outcome: input.expected.outcome,
|
|
325
|
+
evidenceDigest: input.expected.evidenceDigest,
|
|
326
|
+
step: input.expected.step,
|
|
327
|
+
purpose: input.expected.decision.purpose,
|
|
328
|
+
response: input.expected.decision.response,
|
|
329
|
+
binding: input.expected.decision.binding,
|
|
330
|
+
digest: input.expected.decision.digest,
|
|
331
|
+
requirementIds: [...input.expected.decision.requirementIds],
|
|
332
|
+
},
|
|
333
|
+
binding,
|
|
334
|
+
input.caller,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const takeAuthority = (
|
|
340
|
+
authority: VerifiedNativeAuthority,
|
|
341
|
+
binding: AuthorityBinding,
|
|
342
|
+
caller: Caller,
|
|
343
|
+
): AuthorityRecord | null => {
|
|
344
|
+
const record = verifiedAuthorities.get(authority);
|
|
345
|
+
verifiedAuthorities.delete(authority);
|
|
346
|
+
if (!record) return null;
|
|
347
|
+
if (
|
|
348
|
+
record.owner !== binding.owner ||
|
|
349
|
+
record.store !== binding.store ||
|
|
350
|
+
record.root !== binding.root ||
|
|
351
|
+
record.root !== record.store.root ||
|
|
352
|
+
canonicalJson(record.caller) !== canonicalJson(caller)
|
|
353
|
+
)
|
|
354
|
+
return null;
|
|
355
|
+
return record;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export const retireNativeAuthority = (authority: VerifiedNativeAuthority): Provenance | null => {
|
|
359
|
+
const record = verifiedAuthorities.get(authority);
|
|
360
|
+
verifiedAuthorities.delete(authority);
|
|
361
|
+
return record?.provenance ?? null;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const authorityMatches = (
|
|
365
|
+
authority: AuthorityRecord,
|
|
366
|
+
expected: {
|
|
367
|
+
taskId: Id;
|
|
368
|
+
workspaceId: Id;
|
|
369
|
+
decisionId: Id;
|
|
370
|
+
actionRef: Ref;
|
|
371
|
+
taskRevision: Revision;
|
|
372
|
+
workspaceRevision: Revision;
|
|
373
|
+
outcome: AuthorityRecord["outcome"];
|
|
374
|
+
},
|
|
375
|
+
): boolean =>
|
|
376
|
+
authority.kind === "action" &&
|
|
377
|
+
authority.taskId === expected.taskId &&
|
|
378
|
+
authority.workspaceId === expected.workspaceId &&
|
|
379
|
+
authority.decisionId === expected.decisionId &&
|
|
380
|
+
sameRef(authority.actionRef!, expected.actionRef) &&
|
|
381
|
+
authority.taskRevision === expected.taskRevision &&
|
|
382
|
+
authority.workspaceRevision === expected.workspaceRevision &&
|
|
383
|
+
authority.outcome === expected.outcome;
|
|
384
|
+
|
|
385
|
+
const authorityDecisionMatches = (authority: AuthorityRecord, decision: Decision): boolean =>
|
|
386
|
+
authority.purpose === decision.purpose &&
|
|
387
|
+
authority.response === decision.response &&
|
|
388
|
+
authority.digest === decision.digest &&
|
|
389
|
+
canonicalJson(authority.binding) === canonicalJson(decision.binding) &&
|
|
390
|
+
canonicalJson(authority.requirementIds) === canonicalJson(decision.requirementIds);
|
|
391
|
+
|
|
392
|
+
const provenanceMatches = (left: Provenance, right: Provenance): boolean =>
|
|
393
|
+
left.kind === "host_observed" &&
|
|
394
|
+
right.kind === "host_observed" &&
|
|
395
|
+
left.host === right.host &&
|
|
396
|
+
canonicalJson(left.session) === canonicalJson(right.session);
|
|
397
|
+
|
|
398
|
+
/** Return only still-valid, approved decisions for the exact requested binding. */
|
|
399
|
+
export function applicableDecision(
|
|
400
|
+
task: TaskRecord,
|
|
401
|
+
purpose: Decision["purpose"],
|
|
402
|
+
binding: Decision["binding"],
|
|
403
|
+
checkoutRoot?: string,
|
|
404
|
+
): Decision[] {
|
|
405
|
+
if (binding.taskId !== task.id || binding.workspaceId !== task.workspaceId) return [];
|
|
406
|
+
return task.decisions
|
|
407
|
+
.filter(
|
|
408
|
+
(entry) =>
|
|
409
|
+
entry.provenance.kind !== "imported" &&
|
|
410
|
+
decisionMatches(entry, purpose, binding) &&
|
|
411
|
+
(checkoutRoot
|
|
412
|
+
? verifyDecisionContentAtRoot(checkoutRoot, entry.data.binding).ok
|
|
413
|
+
: entry.data.binding.contentRefs.every((reference) => reference.kind !== "file")),
|
|
414
|
+
)
|
|
415
|
+
.map((entry) => entry.data);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const digestBytes = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
419
|
+
export const verifyDecisionContentAtRoot = (
|
|
420
|
+
checkoutRoot: string,
|
|
421
|
+
binding: Decision["binding"],
|
|
422
|
+
): Result<null> => {
|
|
423
|
+
let root: string;
|
|
424
|
+
try {
|
|
425
|
+
root = fs.realpathSync(checkoutRoot);
|
|
426
|
+
} catch {
|
|
427
|
+
return failure("permission_denied", "checkout root is unavailable");
|
|
428
|
+
}
|
|
429
|
+
for (const reference of binding.contentRefs) {
|
|
430
|
+
if (reference.kind !== "file") continue;
|
|
431
|
+
if (!reference.digest)
|
|
432
|
+
return failure("invalid_input", "document references require a byte digest");
|
|
433
|
+
const target = path.resolve(root, reference.path);
|
|
434
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
435
|
+
return failure("invalid_input", "document reference escapes checkout");
|
|
436
|
+
try {
|
|
437
|
+
const relative = path.relative(root, target);
|
|
438
|
+
let current = root;
|
|
439
|
+
for (const segment of relative.split(path.sep)) {
|
|
440
|
+
if (!segment) continue;
|
|
441
|
+
current = path.join(current, segment);
|
|
442
|
+
const stat = fs.lstatSync(current);
|
|
443
|
+
const real = fs.realpathSync(current);
|
|
444
|
+
if (real !== root && !real.startsWith(`${root}${path.sep}`))
|
|
445
|
+
return failure("invalid_input", "document reference escapes checkout");
|
|
446
|
+
if (stat.isSymbolicLink())
|
|
447
|
+
return failure("invalid_input", "document reference uses a symlink");
|
|
448
|
+
if (current === target && !stat.isFile())
|
|
449
|
+
return failure("invalid_input", "document reference is not a regular file");
|
|
450
|
+
if (current !== target && !stat.isDirectory())
|
|
451
|
+
return failure("invalid_input", "document reference ancestor is not a directory");
|
|
452
|
+
}
|
|
453
|
+
if (digestBytes(fs.readFileSync(target)) !== reference.digest)
|
|
454
|
+
return failure("permission_denied", "approved document bytes have changed");
|
|
455
|
+
} catch {
|
|
456
|
+
return failure("invalid_input", "approved document is unavailable");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return success(null, null, null);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const verifyContentRefs = (store: TaskStore, binding: Decision["binding"]): Result<null> =>
|
|
463
|
+
verifyDecisionContentAtRoot(store.root, binding);
|
|
464
|
+
|
|
465
|
+
export const storedDecisionApplicable = (
|
|
466
|
+
store: TaskStore,
|
|
467
|
+
task: TaskRecord,
|
|
468
|
+
purpose: Decision["purpose"],
|
|
469
|
+
binding: Decision["binding"],
|
|
470
|
+
): Entry<Decision>[] =>
|
|
471
|
+
task.decisions.filter(
|
|
472
|
+
(entry) =>
|
|
473
|
+
entry.provenance.kind !== "imported" &&
|
|
474
|
+
decisionMatches(entry, purpose, binding) &&
|
|
475
|
+
verifyDecisionContentAtRoot(store.root, entry.data.binding).ok,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const validateAction = (
|
|
479
|
+
store: TaskStore,
|
|
480
|
+
task: TaskRecord,
|
|
481
|
+
workspaceId: Id,
|
|
482
|
+
input: ReserveActionInput,
|
|
483
|
+
authority: AuthorityRecord,
|
|
484
|
+
): Result<{ entry: Entry<Decision>; workflow: Workflow }> => {
|
|
485
|
+
if (task.status !== "active")
|
|
486
|
+
return failure("invalid_transition", "only active tasks can authorize actions");
|
|
487
|
+
if (!refSchema.safeParse(input.actionRef).success)
|
|
488
|
+
return failure("invalid_input", "action reference is invalid");
|
|
489
|
+
const entry = task.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
490
|
+
if (!entry) return failure("not_found", "decision not found");
|
|
491
|
+
const decision = entry.data;
|
|
492
|
+
if (decision.purpose !== "action")
|
|
493
|
+
return failure("permission_denied", "decision is not an action approval");
|
|
494
|
+
if (
|
|
495
|
+
!authorityMatches(authority, {
|
|
496
|
+
taskId: task.id,
|
|
497
|
+
workspaceId,
|
|
498
|
+
decisionId: input.decisionId,
|
|
499
|
+
actionRef: input.actionRef,
|
|
500
|
+
taskRevision: input.expectedRevision,
|
|
501
|
+
workspaceRevision: input.expectedWorkspaceRevision,
|
|
502
|
+
outcome: "reserve",
|
|
503
|
+
})
|
|
504
|
+
)
|
|
505
|
+
return failure("permission_denied", "native reservation authority is not bound");
|
|
506
|
+
if (!authorityDecisionMatches(authority, decision))
|
|
507
|
+
return failure("permission_denied", "native reservation authority does not match decision");
|
|
508
|
+
if (decision.response !== "approved")
|
|
509
|
+
return failure("permission_denied", "decision was rejected");
|
|
510
|
+
if (
|
|
511
|
+
entry.provenance.kind !== "host_observed" ||
|
|
512
|
+
entry.provenance.receipts.length === 0 ||
|
|
513
|
+
!provenanceMatches(entry.provenance, authority.provenance)
|
|
514
|
+
)
|
|
515
|
+
return failure("permission_denied", "action approval lacks native receipt assurance");
|
|
516
|
+
if (decision.revoked) return failure("permission_denied", "decision is revoked");
|
|
517
|
+
if (decision.digest !== decisionDigest(decision))
|
|
518
|
+
return failure("permission_denied", "decision binding is invalid");
|
|
519
|
+
if (decision.binding.taskId !== task.id || decision.binding.workspaceId !== workspaceId)
|
|
520
|
+
return failure("permission_denied", "decision task or workspace binding is invalid");
|
|
521
|
+
if (input.binding && canonicalJson(input.binding) !== canonicalJson(decision.binding))
|
|
522
|
+
return failure("permission_denied", "action binding does not match the approved decision");
|
|
523
|
+
const content = verifyContentRefs(store, decision.binding);
|
|
524
|
+
if (!content.ok) return content as Result<never>;
|
|
525
|
+
const requestedSteps = parseSteps(decision.binding.approvedContent);
|
|
526
|
+
if (requestedSteps === null)
|
|
527
|
+
return failure("invalid_input", "bounded action steps must be a string array");
|
|
528
|
+
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
|
|
529
|
+
return failure("permission_denied", "bounded action steps do not match approved content");
|
|
530
|
+
if (
|
|
531
|
+
requestedSteps.some((step) => !step || typeof step !== "string") ||
|
|
532
|
+
new Set(requestedSteps).size !== requestedSteps.length
|
|
533
|
+
)
|
|
534
|
+
return failure("invalid_input", "bounded action steps must be unique and non-empty");
|
|
535
|
+
const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
|
|
536
|
+
const workflow = stored
|
|
537
|
+
? { steps: stored.steps, completed: stored.completedSteps }
|
|
538
|
+
: { steps: requestedSteps, completed: [] };
|
|
539
|
+
if (
|
|
540
|
+
workflow.steps.length &&
|
|
541
|
+
requestedSteps.length &&
|
|
542
|
+
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
|
|
543
|
+
)
|
|
544
|
+
return failure("permission_denied", "bounded action scope changed");
|
|
545
|
+
return success(null, null, { entry, workflow });
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
export function reserveAction(input: ReserveActionInput): Result<ActionReservation> {
|
|
549
|
+
if (!input.store || !input.expectedRevision || !input.expectedWorkspaceRevision)
|
|
550
|
+
return failure("invalid_input", "action reservation preconditions are required");
|
|
551
|
+
const authority = takeAuthority(
|
|
552
|
+
input.authority,
|
|
553
|
+
{
|
|
554
|
+
owner: input.authorityOwner,
|
|
555
|
+
store: input.store,
|
|
556
|
+
root: input.store.root,
|
|
557
|
+
},
|
|
558
|
+
input.authorityCaller,
|
|
559
|
+
);
|
|
560
|
+
if (!authority)
|
|
561
|
+
return failure("permission_denied", "native reservation authority is not verified");
|
|
562
|
+
const changed = input.store.mutateTask(
|
|
563
|
+
input.taskId,
|
|
564
|
+
input.expectedRevision,
|
|
565
|
+
(current, mutation) => {
|
|
566
|
+
const workspace = input.store.readWorkspace();
|
|
567
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
568
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
569
|
+
if (workspace.data.revision !== input.expectedWorkspaceRevision)
|
|
570
|
+
return failure("revision_conflict", "workspace revision does not match", {
|
|
571
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
572
|
+
actualWorkspaceRevision: workspace.data.revision,
|
|
573
|
+
});
|
|
574
|
+
const valid = validateAction(input.store, current, workspace.data.id, input, authority);
|
|
575
|
+
if (!valid.ok) return valid as Result<never>;
|
|
576
|
+
const { entry, workflow } = valid.data;
|
|
577
|
+
const existing = entry.data.consumption;
|
|
578
|
+
if (existing?.state === "uncertain")
|
|
579
|
+
return failure("external_outcome_unknown", "previous action outcome is unknown", {
|
|
580
|
+
operation: "reserve_action",
|
|
581
|
+
outcome: "unknown",
|
|
582
|
+
});
|
|
583
|
+
if (existing?.state === "consumed")
|
|
584
|
+
return failure("permission_denied", "action approval is consumed");
|
|
585
|
+
if (existing?.state === "reserved" && workflow.completed.length === 0)
|
|
586
|
+
return failure("permission_denied", "action approval is already reserved");
|
|
587
|
+
const requestedStep = input.step ?? workflow.steps[workflow.completed.length];
|
|
588
|
+
if (
|
|
589
|
+
workflow.steps.length &&
|
|
590
|
+
(!requestedStep || requestedStep !== workflow.steps[workflow.completed.length])
|
|
591
|
+
)
|
|
592
|
+
return failure(
|
|
593
|
+
"permission_denied",
|
|
594
|
+
"action step is outside the approved remaining workflow",
|
|
595
|
+
);
|
|
596
|
+
if (requestedStep && workflow.completed.includes(requestedStep))
|
|
597
|
+
return failure("permission_denied", "action step was already completed");
|
|
598
|
+
const nextDecision = {
|
|
599
|
+
...entry.data,
|
|
600
|
+
consumption: { state: "reserved" as const, at: mutation.now, actionRef: input.actionRef },
|
|
601
|
+
};
|
|
602
|
+
const actionProgress = workflow.steps.length
|
|
603
|
+
? [
|
|
604
|
+
...(current.actionProgress ?? []).filter(
|
|
605
|
+
(progress) => progress.decisionId !== input.decisionId,
|
|
606
|
+
),
|
|
607
|
+
{
|
|
608
|
+
decisionId: input.decisionId,
|
|
609
|
+
steps: [...workflow.steps],
|
|
610
|
+
completedSteps: [...workflow.completed],
|
|
611
|
+
},
|
|
612
|
+
]
|
|
613
|
+
: (current.actionProgress ?? []);
|
|
614
|
+
return success(mutation.revision, null, {
|
|
615
|
+
...current,
|
|
616
|
+
actionProgress,
|
|
617
|
+
decisions: current.decisions.map((candidate) =>
|
|
618
|
+
candidate.id === input.decisionId ? { ...candidate, data: nextDecision } : candidate,
|
|
619
|
+
),
|
|
620
|
+
});
|
|
621
|
+
},
|
|
622
|
+
input.native?.now,
|
|
623
|
+
);
|
|
624
|
+
if (!changed.ok) return changed as Result<never>;
|
|
625
|
+
const workspace = input.store.readWorkspace();
|
|
626
|
+
if (!workspace.ok || !workspace.data)
|
|
627
|
+
return failure("recovery_required", "workspace disappeared after reservation");
|
|
628
|
+
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
629
|
+
if (!entry) return failure("recovery_required", "reserved decision disappeared");
|
|
630
|
+
const workflow = changed.data.actionProgress?.find(
|
|
631
|
+
(progress) => progress.decisionId === input.decisionId,
|
|
632
|
+
) ?? {
|
|
633
|
+
steps: [],
|
|
634
|
+
completedSteps: [],
|
|
635
|
+
};
|
|
636
|
+
return success(changed.data.revision, workspace.data.revision, {
|
|
637
|
+
taskId: input.taskId,
|
|
638
|
+
decisionId: input.decisionId,
|
|
639
|
+
actionRef: input.actionRef,
|
|
640
|
+
taskRevision: changed.data.revision,
|
|
641
|
+
workspaceRevision: workspace.data.revision,
|
|
642
|
+
completedSteps: [...workflow.completedSteps],
|
|
643
|
+
remainingSteps: workflow.steps.slice(workflow.completedSteps.length),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function settleAction(input: SettleActionInput): Result<Entry<Decision>> {
|
|
648
|
+
if (!input.store || !input.taskRevision || !input.workspaceRevision)
|
|
649
|
+
return failure("invalid_input", "action settlement preconditions are required");
|
|
650
|
+
const authority = takeAuthority(
|
|
651
|
+
input.authority,
|
|
652
|
+
{
|
|
653
|
+
owner: input.authorityOwner,
|
|
654
|
+
store: input.store,
|
|
655
|
+
root: input.store.root,
|
|
656
|
+
},
|
|
657
|
+
input.authorityCaller,
|
|
658
|
+
);
|
|
659
|
+
if (!authority)
|
|
660
|
+
return failure("permission_denied", "native settlement authority is not verified");
|
|
661
|
+
if (!refSchema.safeParse(input.actionRef).success)
|
|
662
|
+
return failure("invalid_input", "action reference is invalid");
|
|
663
|
+
if (
|
|
664
|
+
!authorityMatches(authority, {
|
|
665
|
+
taskId: input.taskId,
|
|
666
|
+
workspaceId: authority.workspaceId,
|
|
667
|
+
decisionId: input.decisionId,
|
|
668
|
+
actionRef: input.actionRef,
|
|
669
|
+
taskRevision: input.taskRevision,
|
|
670
|
+
workspaceRevision: input.workspaceRevision,
|
|
671
|
+
outcome: input.outcome,
|
|
672
|
+
})
|
|
673
|
+
)
|
|
674
|
+
return failure("permission_denied", "native settlement authority is not verified");
|
|
675
|
+
let outcomeResult: Result<Entry<Decision>> | null = null;
|
|
676
|
+
const changed = input.store.mutateTask(
|
|
677
|
+
input.taskId,
|
|
678
|
+
input.taskRevision,
|
|
679
|
+
(current, mutation) => {
|
|
680
|
+
const workspace = input.store.readWorkspace();
|
|
681
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
682
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
683
|
+
if (
|
|
684
|
+
!authorityMatches(authority, {
|
|
685
|
+
taskId: input.taskId,
|
|
686
|
+
workspaceId: workspace.data.id,
|
|
687
|
+
decisionId: input.decisionId,
|
|
688
|
+
actionRef: input.actionRef,
|
|
689
|
+
taskRevision: input.taskRevision,
|
|
690
|
+
workspaceRevision: input.workspaceRevision,
|
|
691
|
+
outcome: input.outcome,
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
return failure("permission_denied", "native settlement authority does not match context");
|
|
695
|
+
if (workspace.data.revision !== input.workspaceRevision)
|
|
696
|
+
return failure("revision_conflict", "workspace revision does not match", {
|
|
697
|
+
expectedWorkspaceRevision: input.workspaceRevision,
|
|
698
|
+
actualWorkspaceRevision: workspace.data.revision,
|
|
699
|
+
});
|
|
700
|
+
const entry = current.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
701
|
+
if (!entry) return failure("not_found", "decision not found");
|
|
702
|
+
if (!authorityDecisionMatches(authority, entry.data))
|
|
703
|
+
return failure("permission_denied", "native settlement authority does not match decision");
|
|
704
|
+
if (!provenanceMatches(entry.provenance, authority.provenance))
|
|
705
|
+
return failure("permission_denied", "native settlement caller does not match approval");
|
|
706
|
+
if (entry.data.purpose !== "action" || entry.data.digest !== decisionDigest(entry.data))
|
|
707
|
+
return failure("permission_denied", "decision binding is invalid");
|
|
708
|
+
if (entry.data.consumption?.state === "uncertain")
|
|
709
|
+
return failure("external_outcome_unknown", "previous action outcome is unknown", {
|
|
710
|
+
operation: "settle_action",
|
|
711
|
+
outcome: "unknown",
|
|
712
|
+
});
|
|
713
|
+
if (entry.data.consumption?.state !== "reserved")
|
|
714
|
+
return failure("permission_denied", "action is not reserved");
|
|
715
|
+
if (canonicalJson(entry.data.consumption.actionRef) !== canonicalJson(input.actionRef))
|
|
716
|
+
return failure("permission_denied", "settlement reference does not match reservation");
|
|
717
|
+
const stored = current.actionProgress?.find(
|
|
718
|
+
(progress) => progress.decisionId === input.decisionId,
|
|
719
|
+
);
|
|
720
|
+
const workflow = stored
|
|
721
|
+
? { steps: stored.steps, completed: stored.completedSteps }
|
|
722
|
+
: { steps: [], completed: [] };
|
|
723
|
+
if (
|
|
724
|
+
entry.data.binding.approvedContent &&
|
|
725
|
+
!stored &&
|
|
726
|
+
(parseSteps(entry.data.binding.approvedContent) ?? []).length
|
|
727
|
+
)
|
|
728
|
+
return failure("recovery_required", "bounded action progress is missing");
|
|
729
|
+
const currentStep = input.step ?? workflow.steps[workflow.completed.length];
|
|
730
|
+
if (
|
|
731
|
+
workflow.steps.length &&
|
|
732
|
+
(!currentStep || currentStep !== workflow.steps[workflow.completed.length])
|
|
733
|
+
)
|
|
734
|
+
return failure("permission_denied", "settlement step is outside the approved workflow");
|
|
735
|
+
if (input.outcome === "unknown") {
|
|
736
|
+
outcomeResult = failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
737
|
+
operation: "settle_action",
|
|
738
|
+
outcome: "unknown",
|
|
739
|
+
});
|
|
740
|
+
const uncertain = {
|
|
741
|
+
...entry.data,
|
|
742
|
+
consumption: { ...entry.data.consumption, state: "uncertain" as const, at: mutation.now },
|
|
743
|
+
};
|
|
744
|
+
return success(mutation.revision, null, {
|
|
745
|
+
...current,
|
|
746
|
+
decisions: current.decisions.map((candidate) =>
|
|
747
|
+
candidate.id === input.decisionId ? { ...candidate, data: uncertain } : candidate,
|
|
748
|
+
),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (input.outcome === "not_started") {
|
|
752
|
+
const released = { ...entry.data, consumption: null };
|
|
753
|
+
return success(mutation.revision, null, {
|
|
754
|
+
...current,
|
|
755
|
+
decisions: current.decisions.map((candidate) =>
|
|
756
|
+
candidate.id === input.decisionId ? { ...candidate, data: released } : candidate,
|
|
757
|
+
),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (currentStep) workflow.completed.push(currentStep);
|
|
761
|
+
const complete = !workflow.steps.length || workflow.completed.length >= workflow.steps.length;
|
|
762
|
+
const settled = {
|
|
763
|
+
...entry.data,
|
|
764
|
+
consumption: complete
|
|
765
|
+
? { ...entry.data.consumption, state: "consumed" as const, at: mutation.now }
|
|
766
|
+
: null,
|
|
767
|
+
};
|
|
768
|
+
const actionProgress = workflow.steps.length
|
|
769
|
+
? [
|
|
770
|
+
...(current.actionProgress ?? []).filter(
|
|
771
|
+
(progress) => progress.decisionId !== input.decisionId,
|
|
772
|
+
),
|
|
773
|
+
{
|
|
774
|
+
decisionId: input.decisionId,
|
|
775
|
+
steps: [...workflow.steps],
|
|
776
|
+
completedSteps: [...workflow.completed],
|
|
777
|
+
},
|
|
778
|
+
]
|
|
779
|
+
: (current.actionProgress ?? []);
|
|
780
|
+
const next = {
|
|
781
|
+
...current,
|
|
782
|
+
actionProgress,
|
|
783
|
+
decisions: current.decisions.map((candidate) =>
|
|
784
|
+
candidate.id === input.decisionId ? { ...candidate, data: settled } : candidate,
|
|
785
|
+
),
|
|
786
|
+
};
|
|
787
|
+
if (!complete) {
|
|
788
|
+
outcomeResult = success(mutation.revision, input.workspaceRevision, {
|
|
789
|
+
...entry,
|
|
790
|
+
data: settled,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return success(mutation.revision, null, next);
|
|
794
|
+
},
|
|
795
|
+
input.native?.now,
|
|
796
|
+
);
|
|
797
|
+
if (!changed.ok) return changed as Result<never>;
|
|
798
|
+
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
799
|
+
if (!entry) return failure("recovery_required", "settled decision disappeared");
|
|
800
|
+
if (outcomeResult) return outcomeResult;
|
|
801
|
+
return success(changed.data.revision, input.workspaceRevision, entry);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Apply one opaque, host-attested provider-read outcome to an uncertain action. */
|
|
805
|
+
export function reconcileAction(input: ReconcileActionInput): Result<Entry<Decision>> {
|
|
806
|
+
if (!input.store || !input.taskRevision || !input.workspaceRevision || !input.evidenceDigest)
|
|
807
|
+
return failure("invalid_input", "action reconciliation preconditions are required");
|
|
808
|
+
const authority = takeAuthority(
|
|
809
|
+
input.authority,
|
|
810
|
+
{ owner: input.authorityOwner, store: input.store, root: input.store.root },
|
|
811
|
+
input.authorityCaller,
|
|
812
|
+
);
|
|
813
|
+
if (!authority || authority.kind !== "reconciliation")
|
|
814
|
+
return failure("permission_denied", "native reconciliation authority is not verified");
|
|
815
|
+
if (
|
|
816
|
+
authority.taskId !== input.taskId ||
|
|
817
|
+
authority.decisionId !== input.decisionId ||
|
|
818
|
+
!sameRef(authority.actionRef!, input.actionRef) ||
|
|
819
|
+
authority.taskRevision !== input.taskRevision ||
|
|
820
|
+
authority.workspaceRevision !== input.workspaceRevision ||
|
|
821
|
+
authority.outcome !== input.outcome ||
|
|
822
|
+
authority.evidenceDigest !== input.evidenceDigest ||
|
|
823
|
+
authority.step !== input.step
|
|
824
|
+
)
|
|
825
|
+
return failure("permission_denied", "native reconciliation authority is not bound");
|
|
826
|
+
const changed = input.store.mutateTask(
|
|
827
|
+
input.taskId,
|
|
828
|
+
input.taskRevision,
|
|
829
|
+
(current, mutation) => {
|
|
830
|
+
const workspace = input.store.readWorkspace();
|
|
831
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
832
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
833
|
+
if (authority.workspaceId !== workspace.data.id)
|
|
834
|
+
return failure("permission_denied", "native reconciliation workspace does not match");
|
|
835
|
+
if (workspace.data.revision !== input.workspaceRevision)
|
|
836
|
+
return failure("revision_conflict", "workspace revision does not match");
|
|
837
|
+
const entry = current.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
838
|
+
if (!entry || entry.data.purpose !== "action")
|
|
839
|
+
return failure("not_found", "decision not found");
|
|
840
|
+
if (
|
|
841
|
+
!authorityDecisionMatches(authority, entry.data) ||
|
|
842
|
+
!provenanceMatches(entry.provenance, authority.provenance)
|
|
843
|
+
)
|
|
844
|
+
return failure("permission_denied", "native reconciliation caller does not match approval");
|
|
845
|
+
if (
|
|
846
|
+
entry.data.response !== "approved" ||
|
|
847
|
+
entry.data.revoked !== null ||
|
|
848
|
+
entry.data.digest !== decisionDigest(entry.data) ||
|
|
849
|
+
entry.data.binding.taskId !== current.id ||
|
|
850
|
+
entry.data.binding.workspaceId !== current.workspaceId ||
|
|
851
|
+
entry.data.binding.workspaceId !== workspace.data.id
|
|
852
|
+
)
|
|
853
|
+
return failure("permission_denied", "reconciliation decision binding is invalid");
|
|
854
|
+
if (entry.data.consumption?.state !== "uncertain")
|
|
855
|
+
return failure("external_outcome_unknown", "only an uncertain action can be reconciled");
|
|
856
|
+
if (!sameRef(entry.data.consumption.actionRef, input.actionRef))
|
|
857
|
+
return failure("permission_denied", "reconciliation reference does not match reservation");
|
|
858
|
+
const stored = current.actionProgress?.find(
|
|
859
|
+
(progress) => progress.decisionId === input.decisionId,
|
|
860
|
+
);
|
|
861
|
+
const workflow = stored
|
|
862
|
+
? { steps: [...stored.steps], completed: [...stored.completedSteps] }
|
|
863
|
+
: { steps: [], completed: [] };
|
|
864
|
+
const currentStep = input.step ?? workflow.steps[workflow.completed.length];
|
|
865
|
+
if (
|
|
866
|
+
workflow.steps.length &&
|
|
867
|
+
(!currentStep || currentStep !== workflow.steps[workflow.completed.length])
|
|
868
|
+
)
|
|
869
|
+
return failure("permission_denied", "reconciliation step is outside the approved workflow");
|
|
870
|
+
if (input.outcome === "not_started") {
|
|
871
|
+
return success(mutation.revision, null, {
|
|
872
|
+
...current,
|
|
873
|
+
decisions: current.decisions.map((candidate) =>
|
|
874
|
+
candidate.id === input.decisionId
|
|
875
|
+
? { ...candidate, data: { ...candidate.data, consumption: null } }
|
|
876
|
+
: candidate,
|
|
877
|
+
),
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
if (currentStep) workflow.completed.push(currentStep);
|
|
881
|
+
const complete = !workflow.steps.length || workflow.completed.length >= workflow.steps.length;
|
|
882
|
+
const nextDecision = {
|
|
883
|
+
...entry.data,
|
|
884
|
+
consumption: complete
|
|
885
|
+
? { ...entry.data.consumption, state: "consumed" as const, at: mutation.now }
|
|
886
|
+
: null,
|
|
887
|
+
};
|
|
888
|
+
const actionProgress = workflow.steps.length
|
|
889
|
+
? [
|
|
890
|
+
...(current.actionProgress ?? []).filter(
|
|
891
|
+
(progress) => progress.decisionId !== input.decisionId,
|
|
892
|
+
),
|
|
893
|
+
{
|
|
894
|
+
decisionId: input.decisionId,
|
|
895
|
+
steps: workflow.steps,
|
|
896
|
+
completedSteps: workflow.completed,
|
|
897
|
+
},
|
|
898
|
+
]
|
|
899
|
+
: (current.actionProgress ?? []);
|
|
900
|
+
return success(mutation.revision, null, {
|
|
901
|
+
...current,
|
|
902
|
+
actionProgress,
|
|
903
|
+
decisions: current.decisions.map((candidate) =>
|
|
904
|
+
candidate.id === input.decisionId ? { ...candidate, data: nextDecision } : candidate,
|
|
905
|
+
),
|
|
906
|
+
});
|
|
907
|
+
},
|
|
908
|
+
input.native?.now,
|
|
909
|
+
);
|
|
910
|
+
if (!changed.ok) return changed as Result<never>;
|
|
911
|
+
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
912
|
+
return entry
|
|
913
|
+
? success(changed.data.revision, input.workspaceRevision, entry)
|
|
914
|
+
: failure("recovery_required", "reconciled decision disappeared");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export const bindingCovers = scopeCovers;
|
|
918
|
+
export const verifyDecisionContent = verifyContentRefs;
|