@agentunion/fastaun 0.5.17 → 0.5.19
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/CHANGELOG.md +83 -0
- package/_packed_docs/CHANGELOG.md +83 -0
- package/_packed_docs/INDEX.md +163 -16
- package/_packed_docs/KITE_DOCS_GUIDE.md +52 -20
- package/_packed_docs/agent.md/SCHEMA.md +266 -207
- package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +64 -31
- package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +8 -6
- package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +39 -39
- package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +1 -1
- package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +28 -9
- package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +20 -2
- package/_packed_docs/protocol/agent.md/SCHEMA.md +4 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +5 -2
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +246 -241
- package/_packed_docs/sdk/09-group-rpc-manual.md +46 -14
- package/_packed_docs/sdk/09-message-rpc-manual.md +40 -2
- package/_packed_docs/sdk/09-storage-rpc-manual.md +69 -38
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +25 -5
- package/_packed_docs/sdk/INDEX.md +78 -18
- package/dist/agent-md-schema.d.ts +2 -0
- package/dist/agent-md-schema.js +46 -9
- package/dist/agent-md-schema.js.map +1 -1
- package/dist/agent-md.d.ts +46 -1
- package/dist/agent-md.js +492 -30
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +9 -1
- package/dist/aid-store.js +139 -18
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.d.ts +17 -1
- package/dist/aid.js +67 -7
- package/dist/aid.js.map +1 -1
- package/dist/auth.d.ts +6 -1
- package/dist/auth.js +428 -15
- package/dist/auth.js.map +1 -1
- package/dist/cert-utils.d.ts +1 -1
- package/dist/cert-utils.js +19 -7
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +4 -1
- package/dist/client/delivery.js +264 -68
- package/dist/client/delivery.js.map +1 -1
- package/dist/client/rpc-pipeline.js +29 -14
- package/dist/client/rpc-pipeline.js.map +1 -1
- package/dist/client/v2-e2ee.d.ts +8 -1
- package/dist/client/v2-e2ee.js +893 -529
- package/dist/client/v2-e2ee.js.map +1 -1
- package/dist/client.d.ts +87 -1
- package/dist/client.js +816 -35
- package/dist/client.js.map +1 -1
- package/dist/errors.js +4 -3
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +5 -1
- package/dist/events.js +47 -6
- package/dist/events.js.map +1 -1
- package/dist/facades.d.ts +85 -3
- package/dist/facades.js +104 -4
- package/dist/facades.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +5 -0
- package/dist/keystore/aid-db.js +48 -0
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/index.d.ts +8 -0
- package/dist/keystore/local-identity-store.js +10 -3
- package/dist/keystore/local-identity-store.js.map +1 -1
- package/dist/keystore/local-token-store.d.ts +6 -1
- package/dist/keystore/local-token-store.js +24 -1
- package/dist/keystore/local-token-store.js.map +1 -1
- package/dist/org-protocol.d.ts +98 -0
- package/dist/org-protocol.js +1689 -0
- package/dist/org-protocol.js.map +1 -0
- package/dist/ownership-proof.d.ts +117 -0
- package/dist/ownership-proof.js +436 -0
- package/dist/ownership-proof.js.map +1 -0
- package/dist/ownership-v21.d.ts +178 -0
- package/dist/ownership-v21.js +635 -0
- package/dist/ownership-v21.js.map +1 -0
- package/dist/ownership-workflow.d.ts +82 -0
- package/dist/ownership-workflow.js +872 -0
- package/dist/ownership-workflow.js.map +1 -0
- package/dist/seq-tracker.d.ts +1 -0
- package/dist/seq-tracker.js +10 -1
- package/dist/seq-tracker.js.map +1 -1
- package/dist/tools/cross-sdk-agent.d.ts +89 -1
- package/dist/tools/cross-sdk-agent.js +338 -7
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/transport.d.ts +7 -0
- package/dist/transport.js +108 -4
- package/dist/transport.js.map +1 -1
- package/dist/v2/e2ee/decrypt.d.ts +1 -0
- package/dist/v2/e2ee/decrypt.js +9 -1
- package/dist/v2/e2ee/decrypt.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -8
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isScalar, parseDocument } from 'yaml';
|
|
3
|
+
import { StateError, ValidationError } from './errors.js';
|
|
4
|
+
import { OWNERSHIP_UUID_V4_RE, ownershipAid, ownershipFailure, ownershipInteger, ownershipSha256, parseOwnershipFile, requireOwnershipApplicable } from './ownership-v21.js';
|
|
5
|
+
export const OWNERSHIP_SDK_MESSAGE_TYPE = 'aun.ownership.sdk.v1';
|
|
6
|
+
function participants(task) {
|
|
7
|
+
return [...new Set([task.owner_aid, task.sub_aid, ...(task.new_owner_aid ? [task.new_owner_aid] : [])])];
|
|
8
|
+
}
|
|
9
|
+
function targetOwner(task) {
|
|
10
|
+
return task.operation === 'transfer' ? task.new_owner_aid : task.owner_aid;
|
|
11
|
+
}
|
|
12
|
+
export function parseOwnershipMessage(content, context) {
|
|
13
|
+
let value = content;
|
|
14
|
+
if (typeof content === 'string') {
|
|
15
|
+
try {
|
|
16
|
+
value = JSON.parse(content);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new ValidationError('ownership message must be JSON');
|
|
20
|
+
}
|
|
21
|
+
const document = parseDocument(content, { uniqueKeys: true });
|
|
22
|
+
if (document.errors.length)
|
|
23
|
+
throw new ValidationError('ownership message has duplicate or invalid fields');
|
|
24
|
+
for (const field of ['base_relation_version', 'target_relation_version']) {
|
|
25
|
+
const node = document.get(field, true);
|
|
26
|
+
if (!isScalar(node) || !/^(?:0|[1-9][0-9]*)$/.test(node.source ?? ''))
|
|
27
|
+
throw new ValidationError(`${field} must use an integer`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
31
|
+
throw new ValidationError('ownership message must be an object');
|
|
32
|
+
const message = value;
|
|
33
|
+
const required = ['type', 'operation_id', 'operation', 'action', 'sub_aid', 'owner_aid', 'base_relation_version', 'target_relation_version'];
|
|
34
|
+
const optional = ['new_owner_aid', 'reason'];
|
|
35
|
+
if (required.some((key) => !(key in message)) || Object.keys(message).some((key) => !required.includes(key) && !optional.includes(key)))
|
|
36
|
+
throw new ValidationError('ownership message contains unknown or missing fields');
|
|
37
|
+
if (message.type !== OWNERSHIP_SDK_MESSAGE_TYPE)
|
|
38
|
+
throw ownershipFailure('unsupported_protocol', 'legacy ownership control is unsupported');
|
|
39
|
+
if (!OWNERSHIP_UUID_V4_RE.test(message.operation_id))
|
|
40
|
+
throw new ValidationError('operation_id must be a UUIDv4');
|
|
41
|
+
if (!['bind', 'release', 'transfer'].includes(message.operation))
|
|
42
|
+
throw new ValidationError('ownership operation is invalid');
|
|
43
|
+
ownershipAid(message.sub_aid, 'sub_aid');
|
|
44
|
+
ownershipAid(message.owner_aid, 'owner_aid');
|
|
45
|
+
ownershipInteger(message.base_relation_version, 'base_relation_version', 0);
|
|
46
|
+
if (ownershipInteger(message.target_relation_version, 'target_relation_version') !== message.base_relation_version + 1)
|
|
47
|
+
throw new ValidationError('ownership target version must be base plus one');
|
|
48
|
+
if (message.operation === 'transfer') {
|
|
49
|
+
if (ownershipAid(message.new_owner_aid, 'new_owner_aid') === message.owner_aid)
|
|
50
|
+
throw new ValidationError('transfer target must differ from current owner');
|
|
51
|
+
}
|
|
52
|
+
else if ('new_owner_aid' in message)
|
|
53
|
+
throw new ValidationError('new_owner_aid is only allowed for transfer');
|
|
54
|
+
const allowed = {
|
|
55
|
+
bind: ['bind.request', 'bind.accept', 'bind.reject', 'owner.ready', 'relation.confirmed', 'operation.stopped'],
|
|
56
|
+
release: ['relation.confirmed', 'sub.cleaned', 'operation.stopped'],
|
|
57
|
+
transfer: ['transfer.request', 'transfer.accept', 'transfer.reject', 'transfer.ready', 'owner.ready', 'relation.confirmed', 'operation.stopped'],
|
|
58
|
+
};
|
|
59
|
+
if (!allowed[message.operation].includes(message.action))
|
|
60
|
+
throw new ValidationError('ownership action does not match operation');
|
|
61
|
+
if ('reason' in message && (typeof message.reason !== 'string' || !['bind.reject', 'transfer.reject', 'operation.stopped'].includes(message.action)))
|
|
62
|
+
throw new ValidationError('ownership reason is only allowed for reject or stopped');
|
|
63
|
+
const from = ownershipAid(context.authenticated_from_aid, 'authenticated_from_aid');
|
|
64
|
+
const local = ownershipAid(context.local_aid, 'local_aid');
|
|
65
|
+
if (from === local || !participants(message).includes(from) || !participants(message).includes(local))
|
|
66
|
+
throw ownershipFailure('forbidden', 'ownership sender or receiver is not a remote participant');
|
|
67
|
+
const task = context.task;
|
|
68
|
+
if (task && ['operation_id', 'operation', 'sub_aid', 'owner_aid', 'new_owner_aid', 'base_relation_version', 'target_relation_version'].some((key) => task[key] !== message[key]))
|
|
69
|
+
throw ownershipFailure('conflict', 'ownership fixed task context changed');
|
|
70
|
+
const initiator = task?.initiator_aid;
|
|
71
|
+
let direction = false;
|
|
72
|
+
switch (message.action) {
|
|
73
|
+
case 'bind.request':
|
|
74
|
+
direction = (!initiator || from === initiator) && [message.owner_aid, message.sub_aid].includes(from);
|
|
75
|
+
break;
|
|
76
|
+
case 'bind.accept':
|
|
77
|
+
case 'bind.reject':
|
|
78
|
+
direction = Boolean(initiator && local === initiator && from !== initiator);
|
|
79
|
+
break;
|
|
80
|
+
case 'transfer.request':
|
|
81
|
+
case 'transfer.ready':
|
|
82
|
+
direction = from === message.owner_aid && [message.sub_aid, message.new_owner_aid].includes(local);
|
|
83
|
+
break;
|
|
84
|
+
case 'transfer.accept':
|
|
85
|
+
case 'transfer.reject':
|
|
86
|
+
direction = Boolean(task && local === message.owner_aid && [message.sub_aid, message.new_owner_aid].includes(from));
|
|
87
|
+
break;
|
|
88
|
+
case 'owner.ready':
|
|
89
|
+
direction = Boolean(task && from === targetOwner(message) && local === message.sub_aid);
|
|
90
|
+
break;
|
|
91
|
+
case 'relation.confirmed':
|
|
92
|
+
direction = message.operation === 'release' ? from === message.owner_aid && local === message.sub_aid : Boolean(task && from === message.sub_aid && local !== message.sub_aid);
|
|
93
|
+
break;
|
|
94
|
+
case 'sub.cleaned':
|
|
95
|
+
direction = Boolean(task && from === message.sub_aid && local === message.owner_aid);
|
|
96
|
+
break;
|
|
97
|
+
case 'operation.stopped':
|
|
98
|
+
direction = Boolean(task);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (!direction)
|
|
102
|
+
throw ownershipFailure('forbidden', 'ownership action has an invalid authenticated direction');
|
|
103
|
+
if (['owner.ready', 'transfer.ready'].includes(message.action) && task?.local_decision !== 'accept')
|
|
104
|
+
throw ownershipFailure('forbidden', 'ownership local approval is required');
|
|
105
|
+
if (context.ns) {
|
|
106
|
+
const ns = context.ns;
|
|
107
|
+
if (ns.protocol !== '2.1')
|
|
108
|
+
throw ownershipFailure('unsupported_protocol', 'NS ownership protocol is unavailable');
|
|
109
|
+
const base = ns.relation_version === message.base_relation_version && (message.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === message.owner_aid);
|
|
110
|
+
const target = ns.relation_version === message.target_relation_version && (message.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(message));
|
|
111
|
+
if (['relation.confirmed', 'sub.cleaned'].includes(message.action) ? !target : !base && !target)
|
|
112
|
+
throw ownershipFailure('conflict', 'ownership current NS relation does not match this task');
|
|
113
|
+
}
|
|
114
|
+
return message;
|
|
115
|
+
}
|
|
116
|
+
function localRoles(task, local) {
|
|
117
|
+
const roles = [];
|
|
118
|
+
if (task.owner_aid === local)
|
|
119
|
+
roles.push('owner');
|
|
120
|
+
if (task.sub_aid === local)
|
|
121
|
+
roles.push('sub');
|
|
122
|
+
if (task.new_owner_aid === local)
|
|
123
|
+
roles.push('new_owner');
|
|
124
|
+
return roles;
|
|
125
|
+
}
|
|
126
|
+
export function ownershipTaskResult(task) {
|
|
127
|
+
const result = {
|
|
128
|
+
operation_id: task.operation_id, operation: task.operation, sub_aid: task.sub_aid, owner_aid: task.owner_aid,
|
|
129
|
+
base_relation_version: task.base_relation_version, target_relation_version: task.target_relation_version, status: task.status, step: task.step,
|
|
130
|
+
};
|
|
131
|
+
if (task.operation === 'transfer')
|
|
132
|
+
result.new_owner_aid = task.new_owner_aid;
|
|
133
|
+
for (const field of ['code', 'reason', 'retryable'])
|
|
134
|
+
if (task[field] !== undefined)
|
|
135
|
+
Object.assign(result, { [field]: task[field] });
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
export class OwnershipWorkflow {
|
|
139
|
+
deps;
|
|
140
|
+
static serial = new Map();
|
|
141
|
+
constructor(deps) {
|
|
142
|
+
this.deps = deps;
|
|
143
|
+
}
|
|
144
|
+
async bindOwneraid(ownerAid) {
|
|
145
|
+
return this.start('bind', this.local(), ownershipAid(ownerAid, 'owner_aid'));
|
|
146
|
+
}
|
|
147
|
+
async bindSubaid(subAid) {
|
|
148
|
+
return this.start('bind', ownershipAid(subAid, 'sub_aid'), this.local());
|
|
149
|
+
}
|
|
150
|
+
async releaseSubaid(subAid) {
|
|
151
|
+
return this.start('release', ownershipAid(subAid, 'sub_aid'), this.local());
|
|
152
|
+
}
|
|
153
|
+
async transferSubaid(subAid, newOwnerAid) {
|
|
154
|
+
return this.start('transfer', ownershipAid(subAid, 'sub_aid'), this.local(), ownershipAid(newOwnerAid, 'new_owner_aid'));
|
|
155
|
+
}
|
|
156
|
+
async resumeRegistration(ownerAid, operationId) {
|
|
157
|
+
if (!OWNERSHIP_UUID_V4_RE.test(operationId))
|
|
158
|
+
throw ownershipFailure('invalid', 'registration operation_id is invalid');
|
|
159
|
+
return this.start('bind', this.local(), ownershipAid(ownerAid, 'owner_aid'), undefined, operationId);
|
|
160
|
+
}
|
|
161
|
+
async acceptOwnershipRequest(operationId) {
|
|
162
|
+
return this.decide(operationId, 'accept');
|
|
163
|
+
}
|
|
164
|
+
async rejectOwnershipRequest(operationId, reason) {
|
|
165
|
+
if (reason !== undefined && typeof reason !== 'string')
|
|
166
|
+
throw ownershipFailure('invalid', 'ownership rejection reason must be a string');
|
|
167
|
+
return this.decide(operationId, 'reject', reason);
|
|
168
|
+
}
|
|
169
|
+
async checkOwnershipConsensus(subAid = this.local()) {
|
|
170
|
+
return this.deps.files.checkConsensus(ownershipAid(subAid, 'sub_aid'));
|
|
171
|
+
}
|
|
172
|
+
async receive(from, content) {
|
|
173
|
+
let body = content;
|
|
174
|
+
if (typeof content === 'string') {
|
|
175
|
+
try {
|
|
176
|
+
body = JSON.parse(content);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
183
|
+
return false;
|
|
184
|
+
const type = body.type;
|
|
185
|
+
if (typeof type !== 'string' || !type.startsWith('aun.ownership.'))
|
|
186
|
+
return false;
|
|
187
|
+
if (type !== OWNERSHIP_SDK_MESSAGE_TYPE)
|
|
188
|
+
return true;
|
|
189
|
+
let message;
|
|
190
|
+
try {
|
|
191
|
+
message = parseOwnershipMessage(content, { authenticated_from_aid: from, local_aid: this.local(),
|
|
192
|
+
task: ['bind.request', 'transfer.request'].includes(String(body.action)) || body.operation === 'release' && body.action === 'relation.confirmed' ? undefined : this.load(String(body.operation_id))?.record });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
|
|
196
|
+
throw error;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
let observationError;
|
|
200
|
+
try {
|
|
201
|
+
this.requireApplicable(message.sub_aid);
|
|
202
|
+
await this.deps.files.readAgent(message.sub_aid);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof StateError && error.stringCode === 'not_applicable')
|
|
206
|
+
return true;
|
|
207
|
+
observationError = error;
|
|
208
|
+
}
|
|
209
|
+
return this.runSerialized(message.sub_aid, async () => {
|
|
210
|
+
let handle = this.load(message.operation_id);
|
|
211
|
+
if (handle) {
|
|
212
|
+
try {
|
|
213
|
+
parseOwnershipMessage(content, { authenticated_from_aid: from, local_aid: this.local(), task: handle.record });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
if (!['bind.request', 'transfer.request'].includes(message.action) && !(message.operation === 'release' && message.action === 'relation.confirmed'))
|
|
221
|
+
return true;
|
|
222
|
+
handle = { record: this.newRecord(message, from, 'pending'), revision: null };
|
|
223
|
+
if (message.operation === 'release') {
|
|
224
|
+
const authorization = this.cleanupAuthorization(handle.record);
|
|
225
|
+
if (authorization) {
|
|
226
|
+
handle.record.local_decision = 'accept';
|
|
227
|
+
handle.record.approvals[this.local()] = 'accept';
|
|
228
|
+
handle.record.cleanup_authorization_id = authorization.operation_id;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (message.action === 'sub.cleaned') {
|
|
233
|
+
const began = Date.now();
|
|
234
|
+
const before = await this.readNs(message.sub_aid), after = await this.readNs(message.sub_aid);
|
|
235
|
+
if (Date.now() - began > 30_000 || before.exists !== after.exists || before.etag !== after.etag || before.relation_version !== after.relation_version || before.lock !== after.lock || before.owner_aid !== after.owner_aid || ownershipSha256(before.content) !== ownershipSha256(after.content))
|
|
236
|
+
throw ownershipFailure('unavailable', 'NS changed during sub cleanup verification', true);
|
|
237
|
+
if (!before.exists || before.relation_version !== message.target_relation_version || before.lock !== 'unlocked' || before.owner_aid || !before.etag)
|
|
238
|
+
return true;
|
|
239
|
+
const file = parseOwnershipFile(before.content, 'agent', { expectedSubAid: message.sub_aid });
|
|
240
|
+
if (Object.hasOwn(file.frontmatter, 'owner') || Object.hasOwn(file.frontmatter, 'ownership'))
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
const key = `${from}|${message.operation_id}|${message.action}`;
|
|
244
|
+
if (!handle.record.received.includes(key) && !handle.record.inbox.some((item) => `${item.from}|${item.payload.operation_id}|${item.payload.action}` === key)) {
|
|
245
|
+
handle.record.inbox.push({ from, payload: message });
|
|
246
|
+
await this.save(handle);
|
|
247
|
+
}
|
|
248
|
+
if (observationError)
|
|
249
|
+
await this.recordError(handle, observationError);
|
|
250
|
+
else
|
|
251
|
+
await this.drive(handle);
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async resume() {
|
|
256
|
+
for (const snapshot of this.list()) {
|
|
257
|
+
const record = this.decode(snapshot).record;
|
|
258
|
+
await this.runSerialized(record.sub_aid, async () => {
|
|
259
|
+
const handle = this.load(record.operation_id);
|
|
260
|
+
await this.drive(handle);
|
|
261
|
+
if (handle.record.request_verified && handle.record.local_decision === 'pending' && handle.record.status === 'pending' && handle.record.operation !== 'release')
|
|
262
|
+
this.emit(handle.record, 'ownership.request');
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
local() { return ownershipAid(this.deps.currentAid(), 'local_aid'); }
|
|
267
|
+
requireApplicable(subAid) {
|
|
268
|
+
if (subAid === this.local())
|
|
269
|
+
requireOwnershipApplicable(this.deps.currentType?.());
|
|
270
|
+
}
|
|
271
|
+
store() {
|
|
272
|
+
if (!this.deps.store.loadOwnershipTask || !this.deps.store.listOwnershipTasks || !this.deps.store.compareAndSwapOwnershipTask)
|
|
273
|
+
throw ownershipFailure('persistence_unavailable', 'atomic ownership task storage is unavailable');
|
|
274
|
+
return this.deps.store;
|
|
275
|
+
}
|
|
276
|
+
list() {
|
|
277
|
+
try {
|
|
278
|
+
return this.store().listOwnershipTasks(this.local());
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
load(operationId) {
|
|
285
|
+
try {
|
|
286
|
+
const snapshot = this.store().loadOwnershipTask(this.local(), operationId);
|
|
287
|
+
return snapshot ? this.decode(snapshot) : null;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
decode(snapshot) {
|
|
294
|
+
const record = snapshot.task;
|
|
295
|
+
try {
|
|
296
|
+
if (record.protocol !== '2.1' || !OWNERSHIP_UUID_V4_RE.test(record.operation_id) || record.operation_id !== snapshot.operation_id || !['bind', 'release', 'transfer'].includes(record.operation) || !['pending', 'completed', 'rejected', 'stopped', 'error'].includes(record.status) || typeof record.step !== 'string')
|
|
297
|
+
throw new Error('invalid ownership task');
|
|
298
|
+
ownershipAid(record.sub_aid, 'sub_aid');
|
|
299
|
+
ownershipAid(record.owner_aid, 'owner_aid');
|
|
300
|
+
ownershipAid(record.initiator_aid, 'initiator_aid');
|
|
301
|
+
if (record.operation === 'transfer') {
|
|
302
|
+
if (ownershipAid(record.new_owner_aid, 'new_owner_aid') === record.owner_aid)
|
|
303
|
+
throw new Error('invalid transfer task');
|
|
304
|
+
}
|
|
305
|
+
else if (record.new_owner_aid !== undefined)
|
|
306
|
+
throw new Error('unexpected transfer target');
|
|
307
|
+
if (ownershipInteger(record.target_relation_version, 'target_relation_version') !== ownershipInteger(record.base_relation_version, 'base_relation_version', 0) + 1 || !participants(record).includes(record.initiator_aid) || record.operation !== 'bind' && record.initiator_aid !== record.owner_aid)
|
|
308
|
+
throw new Error('invalid fixed ownership context');
|
|
309
|
+
if (JSON.stringify(record.local_roles) !== JSON.stringify(localRoles(record, this.local())) || !record.local_roles.length || !['pending', 'accept', 'reject'].includes(record.local_decision) || !record.approvals || participants(record).some((aid) => !['pending', 'accept', 'reject'].includes(record.approvals[aid])) || record.approvals[this.local()] !== record.local_decision || !record.files || typeof record.files !== 'object' || Array.isArray(record.files) || !Array.isArray(record.inbox) || !Array.isArray(record.outbox) || !Array.isArray(record.received) || !Array.isArray(record.notices) || typeof record.request_verified !== 'boolean' || typeof record.request_notified !== 'boolean')
|
|
310
|
+
throw new Error('ownership approval or pending state is corrupt');
|
|
311
|
+
if (Object.keys(record.approvals).length !== participants(record).length || record.approvals[record.initiator_aid] !== 'accept' || record.initiator_aid === this.local() && record.local_decision !== 'accept')
|
|
312
|
+
throw new Error('ownership initiator or approval participants are corrupt');
|
|
313
|
+
if (record.status === 'completed' && (record.verified_relation_version !== record.target_relation_version || record.operation !== 'release' && record.local_decision !== 'accept'))
|
|
314
|
+
throw new Error('ownership completion evidence or local approval is corrupt');
|
|
315
|
+
const transferEvidenceRequired = record.operation === 'transfer' && (record.authorization_sha256 !== undefined || record.expires_at !== undefined || (record.status === 'completed' || ['owner', 'agent'].includes(record.step) || record.files.agent || record.files.owner) && record.local_roles.some((role) => role === 'sub' || role === 'new_owner'));
|
|
316
|
+
if (transferEvidenceRequired && (!/^[0-9a-f]{64}$/.test(record.authorization_sha256 ?? '') || !Number.isSafeInteger(record.expires_at) || (record.expires_at ?? 0) <= 0))
|
|
317
|
+
throw new Error('ownership transfer authorization evidence is corrupt');
|
|
318
|
+
for (const [key, saved] of Object.entries(record.files)) {
|
|
319
|
+
if (!['agent', 'owner', 'release', 'transfer', 'cleanup'].includes(key) || !saved || typeof saved.raw !== 'string' || saved.hash !== ownershipSha256(saved.raw) || !saved.condition || typeof saved.condition !== 'object' || Object.keys(saved.condition).length !== 1 || saved.published_revision !== undefined && (typeof saved.published_revision !== 'string' || !saved.published_revision))
|
|
320
|
+
throw new Error('ownership saved file is corrupt');
|
|
321
|
+
if (key === 'agent' ? !(typeof saved.condition.if_match === 'string' && saved.condition.if_match || saved.condition.if_none_match === '*') : !(typeof saved.condition.expected_revision === 'string' && saved.condition.expected_revision))
|
|
322
|
+
throw new Error('ownership original file condition is corrupt');
|
|
323
|
+
const owner = ['agent', 'owner'].includes(key) ? targetOwner(record) : record.owner_aid;
|
|
324
|
+
const file = parseOwnershipFile(saved.raw, key === 'agent' ? 'agent' : 'owner', { expectedSubAid: record.sub_aid, expectedOwnerAid: owner });
|
|
325
|
+
const version = ['release', 'transfer', 'cleanup'].includes(key) ? record.base_relation_version : record.target_relation_version;
|
|
326
|
+
if (key === 'agent' && record.operation === 'release' ? Boolean(file.statement) : file.statement?.relation_version !== version)
|
|
327
|
+
throw new Error('ownership saved file relation is corrupt');
|
|
328
|
+
if (key === 'owner' && file.action !== 'declare' || key === 'release' && file.action !== 'release' || key === 'transfer' && (file.action !== 'transfer_owner' || file.transfer?.new_owner_aid !== record.new_owner_aid || file.transfer?.target_relation_version !== record.target_relation_version || file.transfer?.expires_at !== saved.expires_at))
|
|
329
|
+
throw new Error('ownership saved file action is corrupt');
|
|
330
|
+
}
|
|
331
|
+
if (record.release_cleanup !== undefined) {
|
|
332
|
+
const cleanup = record.release_cleanup;
|
|
333
|
+
if (!['bind', 'transfer'].includes(record.operation) || record.status !== 'completed' || !record.local_roles.includes('sub') || record.local_decision !== 'accept' || typeof cleanup.cleaned !== 'boolean' || !cleanup.files || typeof cleanup.files !== 'object' || Array.isArray(cleanup.files))
|
|
334
|
+
throw new Error('ownership historical cleanup context is corrupt');
|
|
335
|
+
for (const [key, saved] of Object.entries(cleanup.files)) {
|
|
336
|
+
if (key !== 'agent' || !saved || saved.hash !== ownershipSha256(saved.raw) || typeof saved.condition?.if_match !== 'string' || !saved.condition.if_match || Object.keys(saved.condition).length !== 1 || parseOwnershipFile(saved.raw, 'agent', { expectedSubAid: record.sub_aid }).statement)
|
|
337
|
+
throw new Error('ownership historical cleanup file is corrupt');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (record.status === 'completed') {
|
|
341
|
+
const evidence = record.completion_evidence;
|
|
342
|
+
if (!evidence || evidence.sub_aid !== record.sub_aid || evidence.relation_version !== record.target_relation_version || evidence.status !== (record.operation === 'release' ? 'released' : 'consensus') || evidence.lock !== (record.operation === 'release' ? 'unlocked' : 'locked') || evidence.owner_aid !== (record.operation === 'release' ? null : targetOwner(record)) || typeof evidence.agent_md_etag !== 'string' || !evidence.agent_md_etag || !/^[0-9a-f]{64}$/.test(evidence.agent_md_sha256) || typeof evidence.owner_md_revision !== 'string' || typeof evidence.owner_md_sha256 !== 'string' || record.operation !== 'release' && (!evidence.owner_md_revision || !/^[0-9a-f]{64}$/.test(evidence.owner_md_sha256)))
|
|
343
|
+
throw new Error('ownership target acceptance evidence is corrupt');
|
|
344
|
+
if (record.operation !== 'release' && (record.local_roles.includes('sub') && record.files.agent?.hash !== evidence.agent_md_sha256 || this.local() === targetOwner(record) && record.files.owner?.hash !== evidence.owner_md_sha256 || record.operation === 'transfer' && record.local_roles.includes('owner') && !record.files.transfer))
|
|
345
|
+
throw new Error('ownership target file evidence is missing');
|
|
346
|
+
if (record.operation === 'release' && record.local_roles.includes('owner') && !record.files.release)
|
|
347
|
+
throw new Error('ownership release file evidence is missing');
|
|
348
|
+
}
|
|
349
|
+
return { record, revision: snapshot.revision };
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async save(handle) {
|
|
356
|
+
try {
|
|
357
|
+
const snapshot = this.store().compareAndSwapOwnershipTask(this.local(), handle.record.operation_id, handle.revision, handle.record);
|
|
358
|
+
handle.revision = snapshot.revision;
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
throw ownershipFailure('persistence_unavailable', error instanceof Error ? error.message : String(error));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async recordError(handle, error) {
|
|
365
|
+
if (error instanceof StateError && error.stringCode === 'persistence_unavailable')
|
|
366
|
+
throw error;
|
|
367
|
+
const record = handle.record;
|
|
368
|
+
if (record.status === 'error' && record.retryable)
|
|
369
|
+
record.status = 'pending';
|
|
370
|
+
let code = error instanceof ValidationError ? 'invalid' : error instanceof StateError && error.stringCode ? error.stringCode : 'unavailable';
|
|
371
|
+
if (record.status === 'pending' && ['conflict', 'owner_locked', 'forbidden'].includes(code)) {
|
|
372
|
+
try {
|
|
373
|
+
if (await this.confirm(handle)) {
|
|
374
|
+
await this.flush(handle);
|
|
375
|
+
await this.cleanup(handle);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (confirmationError) {
|
|
380
|
+
if (confirmationError instanceof StateError && confirmationError.stringCode === 'persistence_unavailable')
|
|
381
|
+
throw confirmationError;
|
|
382
|
+
error = confirmationError;
|
|
383
|
+
code = error instanceof ValidationError ? 'invalid' : error instanceof StateError && error.stringCode ? error.stringCode : 'unavailable';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
record.code = code;
|
|
387
|
+
record.reason = error instanceof Error ? error.message : String(error);
|
|
388
|
+
record.retryable = record.status === 'pending' && ['unavailable', 'unsupported_protocol'].includes(code);
|
|
389
|
+
if (record.status === 'pending' && code === 'invalid')
|
|
390
|
+
record.status = 'error';
|
|
391
|
+
else if (record.status === 'pending' && ['conflict', 'owner_locked', 'forbidden'].includes(code))
|
|
392
|
+
record.status = 'stopped';
|
|
393
|
+
await this.save(handle);
|
|
394
|
+
this.emit(record, record.status === 'stopped' ? 'ownership.stopped' : 'ownership.error');
|
|
395
|
+
}
|
|
396
|
+
async runSerialized(subAid, work) {
|
|
397
|
+
const key = `${this.local()}|${subAid}`;
|
|
398
|
+
const before = OwnershipWorkflow.serial.get(key) ?? Promise.resolve();
|
|
399
|
+
const next = before.catch(() => { }).then(work);
|
|
400
|
+
OwnershipWorkflow.serial.set(key, next);
|
|
401
|
+
try {
|
|
402
|
+
return await next;
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
if (OwnershipWorkflow.serial.get(key) === next)
|
|
406
|
+
OwnershipWorkflow.serial.delete(key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
newRecord(context, initiator, decision) {
|
|
410
|
+
const record = {
|
|
411
|
+
protocol: '2.1', operation_id: context.operation_id, operation: context.operation, sub_aid: context.sub_aid, owner_aid: context.owner_aid,
|
|
412
|
+
base_relation_version: context.base_relation_version, target_relation_version: context.target_relation_version, status: 'pending', step: 'approval', retryable: true,
|
|
413
|
+
initiator_aid: initiator, local_roles: localRoles(context, this.local()), local_decision: decision,
|
|
414
|
+
approvals: Object.fromEntries(participants(context).map((name) => [name, name === initiator ? 'accept' : 'pending'])),
|
|
415
|
+
files: {}, inbox: [], received: [], outbox: [], notices: [], request_verified: initiator === this.local(), request_notified: false,
|
|
416
|
+
};
|
|
417
|
+
if (context.operation === 'transfer')
|
|
418
|
+
record.new_owner_aid = context.new_owner_aid;
|
|
419
|
+
record.approvals[this.local()] = decision;
|
|
420
|
+
return record;
|
|
421
|
+
}
|
|
422
|
+
async start(operation, subAid, ownerAid, newOwnerAid, operationId) {
|
|
423
|
+
this.requireApplicable(subAid);
|
|
424
|
+
return this.runSerialized(subAid, async () => {
|
|
425
|
+
const existing = operationId ? this.load(operationId) : null;
|
|
426
|
+
if (existing) {
|
|
427
|
+
const record = existing.record;
|
|
428
|
+
if (record.operation !== operation || record.sub_aid !== subAid || record.owner_aid !== ownerAid || record.new_owner_aid !== newOwnerAid || record.initiator_aid !== this.local())
|
|
429
|
+
throw ownershipFailure('conflict', 'saved registration task context changed');
|
|
430
|
+
await this.drive(existing);
|
|
431
|
+
return ownershipTaskResult(record);
|
|
432
|
+
}
|
|
433
|
+
let ns;
|
|
434
|
+
for (const snapshot of this.list()) {
|
|
435
|
+
const active = this.decode(snapshot), record = active.record;
|
|
436
|
+
if (record.sub_aid !== subAid || record.status !== 'pending' && !(record.status === 'error' && record.retryable))
|
|
437
|
+
continue;
|
|
438
|
+
const same = record.operation === operation && record.owner_aid === ownerAid && record.new_owner_aid === newOwnerAid && record.initiator_aid === this.local();
|
|
439
|
+
try {
|
|
440
|
+
ns = await this.readNs(subAid);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (!same)
|
|
444
|
+
throw error;
|
|
445
|
+
await this.recordError(active, error);
|
|
446
|
+
return ownershipTaskResult(record);
|
|
447
|
+
}
|
|
448
|
+
const base = ns.relation_version === record.base_relation_version && (record.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === record.owner_aid);
|
|
449
|
+
const target = ns.relation_version === record.target_relation_version && (record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(record));
|
|
450
|
+
if (!base && !target) {
|
|
451
|
+
await this.recordError(active, ownershipFailure('conflict', 'the operation was superseded by a different relation result'));
|
|
452
|
+
const result = ownershipTaskResult(record);
|
|
453
|
+
if (same && result.status !== 'stopped')
|
|
454
|
+
return result;
|
|
455
|
+
if (result.status !== 'stopped' && result.status !== 'completed')
|
|
456
|
+
throw ownershipFailure(record.code ?? 'unavailable', record.reason ?? 'the original target cannot be confirmed', record.retryable);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (!same || operationId && operationId !== record.operation_id)
|
|
460
|
+
throw ownershipFailure('conflict', 'a different active ownership intent already exists');
|
|
461
|
+
await this.drive(active);
|
|
462
|
+
return ownershipTaskResult(record);
|
|
463
|
+
}
|
|
464
|
+
if (operation === 'transfer' && newOwnerAid === ownerAid)
|
|
465
|
+
throw ownershipFailure('invalid', 'new owner must differ from original owner');
|
|
466
|
+
ns = await this.readNs(subAid);
|
|
467
|
+
if (operation === 'bind' && ns.lock !== 'unlocked')
|
|
468
|
+
throw ownershipFailure('owner_locked', 'the current owner relation is locked');
|
|
469
|
+
if (!ns.exists)
|
|
470
|
+
throw ownershipFailure('unavailable', 'initial agent.md is not published', true);
|
|
471
|
+
if (operation === 'bind' && parseOwnershipFile(ns.content, 'agent', { expectedSubAid: subAid }).frontmatter.type === 'human')
|
|
472
|
+
throw ownershipFailure('invalid', 'human cannot declare an owner');
|
|
473
|
+
if (operation !== 'bind') {
|
|
474
|
+
const consensus = await this.deps.files.checkConsensus(subAid);
|
|
475
|
+
if (consensus.status !== 'consensus' || consensus.owner_aid !== ownerAid || consensus.relation_version !== ns.relation_version)
|
|
476
|
+
throw ownershipFailure(consensus.status === 'invalid' ? 'invalid' : consensus.status === 'unavailable' ? consensus.code ?? 'unavailable' : 'forbidden', 'the current original owner consensus cannot be verified', consensus.status === 'unavailable');
|
|
477
|
+
}
|
|
478
|
+
ownershipInteger(ns.relation_version + 1, 'target_relation_version');
|
|
479
|
+
const record = this.newRecord({ operation_id: operationId ?? randomUUID(), operation, sub_aid: subAid, owner_aid: ownerAid, new_owner_aid: newOwnerAid, base_relation_version: ns.relation_version, target_relation_version: ns.relation_version + 1 }, this.local(), 'accept');
|
|
480
|
+
if (operation !== 'release')
|
|
481
|
+
for (const to of participants(record))
|
|
482
|
+
if (to !== this.local())
|
|
483
|
+
this.enqueue(record, to, operation === 'bind' ? 'bind.request' : 'transfer.request');
|
|
484
|
+
const handle = { record, revision: null };
|
|
485
|
+
await this.save(handle);
|
|
486
|
+
await this.drive(handle);
|
|
487
|
+
return ownershipTaskResult(record);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async decide(operationId, decision, reason) {
|
|
491
|
+
const initial = this.load(operationId);
|
|
492
|
+
if (!initial)
|
|
493
|
+
throw ownershipFailure('operation_not_found', 'no local ownership request matches operation_id');
|
|
494
|
+
return this.runSerialized(initial.record.sub_aid, async () => {
|
|
495
|
+
const handle = this.load(operationId);
|
|
496
|
+
const record = handle.record;
|
|
497
|
+
if (record.operation === 'release' || record.initiator_aid === this.local() || !record.request_verified)
|
|
498
|
+
throw ownershipFailure('forbidden', 'this task is not a verified local approval request');
|
|
499
|
+
if (record.local_decision !== 'pending') {
|
|
500
|
+
if (record.local_decision !== decision)
|
|
501
|
+
throw ownershipFailure('conflict', 'the persisted local approval cannot be changed');
|
|
502
|
+
await this.drive(handle);
|
|
503
|
+
return ownershipTaskResult(record);
|
|
504
|
+
}
|
|
505
|
+
if (record.status === 'error' && record.retryable)
|
|
506
|
+
record.status = 'pending';
|
|
507
|
+
if (record.status !== 'pending')
|
|
508
|
+
throw ownershipFailure('conflict', 'ownership approval stage has ended');
|
|
509
|
+
const ns = await this.readNs(record.sub_aid);
|
|
510
|
+
if (ns.relation_version !== record.base_relation_version || (record.operation === 'bind' ? ns.lock !== 'unlocked' : ns.lock !== 'locked' || ns.owner_aid !== record.owner_aid))
|
|
511
|
+
throw ownershipFailure('conflict', 'the original relation changed before approval');
|
|
512
|
+
record.local_decision = decision;
|
|
513
|
+
record.approvals[this.local()] = decision;
|
|
514
|
+
this.enqueue(record, record.initiator_aid, `${record.operation}.${decision === 'accept' ? 'accept' : 'reject'}`, reason);
|
|
515
|
+
if (decision === 'reject') {
|
|
516
|
+
record.status = 'rejected';
|
|
517
|
+
record.step = 'approval';
|
|
518
|
+
record.retryable = false;
|
|
519
|
+
if (reason !== undefined)
|
|
520
|
+
record.reason = reason;
|
|
521
|
+
}
|
|
522
|
+
await this.save(handle);
|
|
523
|
+
this.emit(record, decision === 'reject' ? 'ownership.rejected' : 'ownership.progress');
|
|
524
|
+
await this.drive(handle);
|
|
525
|
+
return ownershipTaskResult(record);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
enqueue(record, to, action, reason) {
|
|
529
|
+
if (to === this.local())
|
|
530
|
+
return;
|
|
531
|
+
const key = `${to}|${action}`;
|
|
532
|
+
if (record.notices.includes(key))
|
|
533
|
+
return;
|
|
534
|
+
const payload = { type: OWNERSHIP_SDK_MESSAGE_TYPE, operation_id: record.operation_id, operation: record.operation, action,
|
|
535
|
+
sub_aid: record.sub_aid, owner_aid: record.owner_aid, base_relation_version: record.base_relation_version, target_relation_version: record.target_relation_version };
|
|
536
|
+
if (record.operation === 'transfer')
|
|
537
|
+
payload.new_owner_aid = record.new_owner_aid;
|
|
538
|
+
if (reason !== undefined)
|
|
539
|
+
payload.reason = reason;
|
|
540
|
+
record.outbox.push({ to, payload });
|
|
541
|
+
record.notices.push(key);
|
|
542
|
+
}
|
|
543
|
+
async flush(handle) {
|
|
544
|
+
while (handle.record.outbox.length) {
|
|
545
|
+
const message = handle.record.outbox[0];
|
|
546
|
+
const ns = await this.readNs(handle.record.sub_aid);
|
|
547
|
+
const record = handle.record;
|
|
548
|
+
const base = ns.relation_version === record.base_relation_version && (record.operation === 'bind' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === record.owner_aid);
|
|
549
|
+
const target = ns.relation_version === record.target_relation_version && (record.operation === 'release' ? ns.lock === 'unlocked' && !ns.owner_aid : ns.lock === 'locked' && ns.owner_aid === targetOwner(record));
|
|
550
|
+
const requiresTarget = ['relation.confirmed', 'sub.cleaned'].includes(message.payload.action);
|
|
551
|
+
if (requiresTarget ? !target : !base) {
|
|
552
|
+
record.outbox.shift();
|
|
553
|
+
await this.save(handle);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (message.payload.action === 'transfer.ready' && record.status !== 'completed') {
|
|
557
|
+
const expiresAt = record.files.transfer?.expires_at;
|
|
558
|
+
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt <= 0)
|
|
559
|
+
throw ownershipFailure('persistence_unavailable', 'persisted transfer authorization expiry is missing or invalid');
|
|
560
|
+
if (expiresAt <= Math.trunc(Date.now() / 1000))
|
|
561
|
+
throw ownershipFailure('conflict', 'transfer authorization expired');
|
|
562
|
+
}
|
|
563
|
+
await this.deps.send(message.to, message.payload);
|
|
564
|
+
handle.record.outbox.shift();
|
|
565
|
+
await this.save(handle);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async readNs(subAid) {
|
|
569
|
+
const ns = await this.deps.files.readAgent(subAid);
|
|
570
|
+
const file = ns.exists && !(ns.lock === 'unlocked' && ns.relation_version > 0) ? await this.deps.files.verifyFile(ns.content, 'agent', subAid) : undefined;
|
|
571
|
+
if (ns.lock === 'unlocked' && ns.relation_version === 0 && file?.statement)
|
|
572
|
+
throw ownershipFailure('unavailable', 'NS relation metadata does not match agent.md', true);
|
|
573
|
+
const currentOwner = ns.lock === 'locked' ? file?.statement?.owner_aid : null;
|
|
574
|
+
if (ns.lock === 'locked' && (!file?.statement || file.statement.relation_version !== ns.relation_version) || ns.owner_aid !== undefined && ns.owner_aid !== currentOwner)
|
|
575
|
+
throw ownershipFailure('unavailable', 'NS owner metadata and signed agent.md do not agree', true);
|
|
576
|
+
return { ...ns, owner_aid: currentOwner };
|
|
577
|
+
}
|
|
578
|
+
async processInbox(handle) {
|
|
579
|
+
const record = handle.record;
|
|
580
|
+
while (record.inbox.length) {
|
|
581
|
+
const item = record.inbox[0];
|
|
582
|
+
const ns = await this.readNs(record.sub_aid);
|
|
583
|
+
try {
|
|
584
|
+
parseOwnershipMessage(item.payload, { authenticated_from_aid: item.from, local_aid: this.local(), task: record, ns });
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
record.inbox.shift();
|
|
588
|
+
record.received.push(`${item.from}|${item.payload.operation_id}|${item.payload.action}`);
|
|
589
|
+
if (record.status === 'pending' && ['bind.request', 'transfer.request'].includes(item.payload.action)) {
|
|
590
|
+
record.status = 'stopped';
|
|
591
|
+
record.step = 'conflict';
|
|
592
|
+
record.retryable = false;
|
|
593
|
+
}
|
|
594
|
+
await this.save(handle);
|
|
595
|
+
this.emit(record, 'ownership.error', { code: error instanceof StateError ? error.stringCode : 'invalid', reason: error instanceof Error ? error.message : String(error), retryable: false });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const action = item.payload.action;
|
|
599
|
+
if (['bind.request', 'transfer.request'].includes(action))
|
|
600
|
+
record.request_verified = true;
|
|
601
|
+
if (['bind.accept', 'transfer.accept', 'bind.reject', 'transfer.reject'].includes(action)) {
|
|
602
|
+
if (record.approvals[item.from] === 'pending' && record.status === 'pending') {
|
|
603
|
+
const rejected = action.endsWith('.reject');
|
|
604
|
+
record.approvals[item.from] = rejected ? 'reject' : 'accept';
|
|
605
|
+
if (rejected) {
|
|
606
|
+
record.status = 'rejected';
|
|
607
|
+
record.step = 'approval';
|
|
608
|
+
record.retryable = false;
|
|
609
|
+
if (item.payload.reason !== undefined)
|
|
610
|
+
record.reason = item.payload.reason;
|
|
611
|
+
if (record.operation === 'transfer')
|
|
612
|
+
for (const to of participants(record))
|
|
613
|
+
if (to !== item.from)
|
|
614
|
+
this.enqueue(record, to, 'operation.stopped', item.payload.reason);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (action === 'operation.stopped' && record.status === 'pending' && !(await this.confirm(handle))) {
|
|
619
|
+
record.status = 'stopped';
|
|
620
|
+
record.step = 'stopped';
|
|
621
|
+
record.retryable = false;
|
|
622
|
+
}
|
|
623
|
+
if (action === 'sub.cleaned') {
|
|
624
|
+
const file = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid });
|
|
625
|
+
if (!file.statement)
|
|
626
|
+
record.cleanup_status = 'cleaned';
|
|
627
|
+
}
|
|
628
|
+
if (record.operation === 'release')
|
|
629
|
+
record.request_verified = true;
|
|
630
|
+
record.inbox.shift();
|
|
631
|
+
record.received.push(`${item.from}|${item.payload.operation_id}|${action}`);
|
|
632
|
+
const request = record.request_verified && record.local_decision === 'pending' && record.operation !== 'release' && !record.request_notified;
|
|
633
|
+
if (request)
|
|
634
|
+
record.request_notified = true;
|
|
635
|
+
await this.save(handle);
|
|
636
|
+
if (request)
|
|
637
|
+
this.emit(record, 'ownership.request');
|
|
638
|
+
if (record.status === 'rejected')
|
|
639
|
+
this.emit(record, 'ownership.rejected');
|
|
640
|
+
if (record.status === 'stopped')
|
|
641
|
+
this.emit(record, 'ownership.stopped');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
fileTask(handle) {
|
|
645
|
+
const record = handle.record;
|
|
646
|
+
const task = { operation_id: record.operation_id, operation: record.operation, sub_aid: record.sub_aid, owner_aid: record.owner_aid,
|
|
647
|
+
new_owner_aid: record.new_owner_aid, base_relation_version: record.base_relation_version, target_relation_version: record.target_relation_version,
|
|
648
|
+
files: record.files, authorization_sha256: record.authorization_sha256, expires_at: record.expires_at,
|
|
649
|
+
persist: async () => {
|
|
650
|
+
record.authorization_sha256 = task.authorization_sha256;
|
|
651
|
+
record.expires_at = task.expires_at;
|
|
652
|
+
if (record.operation === 'transfer' && task.authorization_sha256 !== undefined && task.expires_at !== undefined && record.step === 'approval')
|
|
653
|
+
record.step = this.local() === targetOwner(record) ? 'owner' : 'agent';
|
|
654
|
+
await this.save(handle);
|
|
655
|
+
} };
|
|
656
|
+
return task;
|
|
657
|
+
}
|
|
658
|
+
async drive(handle) {
|
|
659
|
+
const record = handle.record;
|
|
660
|
+
if (record.status === 'error' && record.retryable) {
|
|
661
|
+
record.status = 'pending';
|
|
662
|
+
await this.save(handle);
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
await this.processInbox(handle);
|
|
666
|
+
await this.flush(handle);
|
|
667
|
+
if (record.status === 'rejected' || record.status === 'stopped' || record.status === 'error')
|
|
668
|
+
return;
|
|
669
|
+
if (!record.request_verified)
|
|
670
|
+
return;
|
|
671
|
+
if (record.status === 'completed') {
|
|
672
|
+
const observed = await this.deps.files.checkConsensus(record.sub_aid);
|
|
673
|
+
await this.resumeSubCleanup(handle, observed);
|
|
674
|
+
if (await this.confirm(handle, observed))
|
|
675
|
+
await this.cleanup(handle);
|
|
676
|
+
await this.flush(handle);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (record.operation === 'release') {
|
|
680
|
+
if (await this.confirm(handle)) {
|
|
681
|
+
await this.cleanup(handle);
|
|
682
|
+
await this.flush(handle);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (this.local() === record.owner_aid) {
|
|
686
|
+
record.step = 'release';
|
|
687
|
+
await this.save(handle);
|
|
688
|
+
await this.deps.files.revokeSubaid(this.fileTask(handle));
|
|
689
|
+
}
|
|
690
|
+
if (await this.confirm(handle))
|
|
691
|
+
await this.cleanup(handle);
|
|
692
|
+
await this.flush(handle);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (record.local_decision !== 'accept')
|
|
696
|
+
return;
|
|
697
|
+
if (await this.confirm(handle)) {
|
|
698
|
+
await this.cleanup(handle);
|
|
699
|
+
await this.flush(handle);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const ns = await this.readNs(record.sub_aid);
|
|
703
|
+
if (ns.relation_version !== record.base_relation_version || record.operation === 'bind' && ns.lock !== 'unlocked' || record.operation === 'transfer' && (ns.lock !== 'locked' || ns.owner_aid !== record.owner_aid)) {
|
|
704
|
+
throw ownershipFailure('conflict', 'the original relation was replaced');
|
|
705
|
+
}
|
|
706
|
+
if (record.expires_at !== undefined && record.expires_at <= Math.trunc(Date.now() / 1000))
|
|
707
|
+
throw ownershipFailure('conflict', 'transfer authorization expired');
|
|
708
|
+
const approved = participants(record).every((name) => record.approvals[name] === 'accept');
|
|
709
|
+
if (record.operation === 'bind' && !approved || record.operation === 'transfer' && this.local() === record.owner_aid && !approved)
|
|
710
|
+
return;
|
|
711
|
+
delete record.code;
|
|
712
|
+
delete record.reason;
|
|
713
|
+
record.retryable = true;
|
|
714
|
+
if (record.operation === 'transfer' && this.local() === record.owner_aid) {
|
|
715
|
+
record.step = 'transfer';
|
|
716
|
+
await this.save(handle);
|
|
717
|
+
await this.deps.files.transferOwneraid(this.fileTask(handle));
|
|
718
|
+
for (const to of participants(record))
|
|
719
|
+
this.enqueue(record, to, 'transfer.ready');
|
|
720
|
+
await this.save(handle);
|
|
721
|
+
await this.flush(handle);
|
|
722
|
+
}
|
|
723
|
+
if (record.operation === 'transfer') {
|
|
724
|
+
const original = await this.deps.files.readOwner(record.owner_aid, record.sub_aid);
|
|
725
|
+
if (original.exists && (await this.deps.files.verifyFile(original.content, 'owner', record.sub_aid, record.owner_aid)).action === 'declare')
|
|
726
|
+
return;
|
|
727
|
+
await this.deps.files.requireTransfer(this.fileTask(handle));
|
|
728
|
+
}
|
|
729
|
+
if (this.local() === targetOwner(record)) {
|
|
730
|
+
record.step = 'owner';
|
|
731
|
+
await this.save(handle);
|
|
732
|
+
await this.deps.files.declareSubaid(this.fileTask(handle));
|
|
733
|
+
this.enqueue(record, record.sub_aid, 'owner.ready');
|
|
734
|
+
await this.save(handle);
|
|
735
|
+
await this.flush(handle);
|
|
736
|
+
}
|
|
737
|
+
if (this.local() === record.sub_aid) {
|
|
738
|
+
record.step = 'agent';
|
|
739
|
+
await this.save(handle);
|
|
740
|
+
await this.deps.files.declareOwneraid(this.fileTask(handle));
|
|
741
|
+
}
|
|
742
|
+
const completed = await this.confirm(handle);
|
|
743
|
+
await this.flush(handle);
|
|
744
|
+
if (completed)
|
|
745
|
+
await this.cleanup(handle);
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
await this.recordError(handle, error);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async confirm(handle, observed) {
|
|
752
|
+
const record = handle.record;
|
|
753
|
+
const result = observed ?? await this.deps.files.checkConsensus(record.sub_aid, record.operation === 'release' ? undefined : { owner_aid: targetOwner(record), relation_version: record.target_relation_version });
|
|
754
|
+
if (result.status === 'invalid' || result.status === 'unavailable')
|
|
755
|
+
throw ownershipFailure(result.code ?? result.status, result.reason ?? 'ownership consensus cannot be verified', result.retryable);
|
|
756
|
+
const reached = result.relation_version === record.target_relation_version && (record.operation === 'release' ? result.status === 'released' && result.lock === 'unlocked' : result.status === 'consensus' && result.owner_aid === targetOwner(record));
|
|
757
|
+
if (!reached)
|
|
758
|
+
return false;
|
|
759
|
+
if (record.operation === 'release' && record.local_roles.includes('owner') && !record.files.release)
|
|
760
|
+
throw ownershipFailure('conflict', 'local release file evidence is missing');
|
|
761
|
+
if (record.operation === 'transfer' && record.local_roles.includes('owner') && !record.files.transfer)
|
|
762
|
+
throw ownershipFailure('conflict', 'local transfer file evidence is missing');
|
|
763
|
+
if (record.operation === 'release' && this.local() === record.sub_aid && record.local_decision !== 'accept') {
|
|
764
|
+
const ns = await this.deps.files.readAgent(record.sub_aid);
|
|
765
|
+
if (parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
|
|
766
|
+
record.step = 'cleanup';
|
|
767
|
+
if (record.cleanup_status !== 'action_required') {
|
|
768
|
+
record.cleanup_status = 'action_required';
|
|
769
|
+
await this.save(handle);
|
|
770
|
+
this.emit(record, 'ownership.action_required');
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (record.operation !== 'release' && (record.local_decision !== 'accept' || this.local() === record.sub_aid && (!record.files.agent || record.files.agent.hash !== result.agent_md_sha256) || this.local() === targetOwner(record) && (!record.files.owner || record.files.owner.hash !== result.owner_md_sha256)))
|
|
776
|
+
return false;
|
|
777
|
+
if (record.status !== 'completed' && record.operation !== 'release' && (this.local() === record.sub_aid && record.files.agent?.published_revision !== undefined && record.files.agent.published_revision !== result.agent_md_etag || this.local() === targetOwner(record) && record.files.owner?.published_revision !== undefined && record.files.owner.published_revision !== result.owner_md_revision))
|
|
778
|
+
throw ownershipFailure('conflict', 'published ownership file revision changed');
|
|
779
|
+
if (record.status !== 'completed') {
|
|
780
|
+
record.status = 'completed';
|
|
781
|
+
record.step = 'confirmed';
|
|
782
|
+
record.verified_relation_version = result.relation_version;
|
|
783
|
+
record.completion_evidence = { ...result };
|
|
784
|
+
delete record.code;
|
|
785
|
+
delete record.reason;
|
|
786
|
+
record.retryable = false;
|
|
787
|
+
if (record.operation === 'release' && this.local() === record.owner_aid)
|
|
788
|
+
this.enqueue(record, record.sub_aid, 'relation.confirmed');
|
|
789
|
+
else if (record.operation !== 'release' && this.local() === record.sub_aid)
|
|
790
|
+
for (const to of participants(record))
|
|
791
|
+
this.enqueue(record, to, 'relation.confirmed');
|
|
792
|
+
await this.save(handle);
|
|
793
|
+
this.emit(record, 'ownership.completed');
|
|
794
|
+
}
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
async resumeSubCleanup(handle, observed) {
|
|
798
|
+
const record = handle.record;
|
|
799
|
+
if (!['bind', 'transfer'].includes(record.operation) || !record.local_roles.includes('sub') || record.local_decision !== 'accept' || observed.status !== 'released' || observed.relation_version !== record.target_relation_version + 1 || record.release_cleanup?.cleaned)
|
|
800
|
+
return;
|
|
801
|
+
const ns = await this.readNs(record.sub_aid);
|
|
802
|
+
if (!ns.exists || ns.lock !== 'unlocked' || ns.relation_version !== observed.relation_version || ns.etag !== observed.agent_md_etag || ownershipSha256(ns.content) !== observed.agent_md_sha256)
|
|
803
|
+
throw ownershipFailure('unavailable', 'NS changed during historical cleanup observation', true);
|
|
804
|
+
const statement = parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement;
|
|
805
|
+
if (statement && (statement.owner_aid !== targetOwner(record) || statement.relation_version !== record.target_relation_version))
|
|
806
|
+
throw ownershipFailure('conflict', 'historical cleanup fields belong to another relation');
|
|
807
|
+
if (!record.release_cleanup) {
|
|
808
|
+
record.release_cleanup = { files: {}, cleaned: false };
|
|
809
|
+
await this.save(handle);
|
|
810
|
+
}
|
|
811
|
+
await this.deps.files.revokeOwneraid({ operation: 'release', sub_aid: record.sub_aid, owner_aid: targetOwner(record),
|
|
812
|
+
base_relation_version: record.target_relation_version, target_relation_version: observed.relation_version,
|
|
813
|
+
files: record.release_cleanup.files, persist: () => this.save(handle) });
|
|
814
|
+
record.release_cleanup.cleaned = true;
|
|
815
|
+
await this.save(handle);
|
|
816
|
+
this.emit(record, 'ownership.progress');
|
|
817
|
+
}
|
|
818
|
+
cleanupAuthorization(task) {
|
|
819
|
+
for (const snapshot of this.list()) {
|
|
820
|
+
const candidate = this.decode(snapshot).record;
|
|
821
|
+
if (['bind', 'transfer'].includes(candidate.operation) && candidate.local_decision === 'accept' && candidate.local_roles.includes('sub') && candidate.sub_aid === task.sub_aid && targetOwner(candidate) === task.owner_aid && candidate.target_relation_version === task.base_relation_version)
|
|
822
|
+
return candidate;
|
|
823
|
+
}
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
async cleanup(handle) {
|
|
827
|
+
const record = handle.record;
|
|
828
|
+
if (record.operation === 'bind')
|
|
829
|
+
return;
|
|
830
|
+
if (record.operation === 'release' && this.local() === record.sub_aid && record.cleanup_status !== 'cleaned') {
|
|
831
|
+
const ns = await this.readNs(record.sub_aid);
|
|
832
|
+
if (ns.exists && ns.lock === 'unlocked' && ns.relation_version === record.target_relation_version && !parseOwnershipFile(ns.content, 'agent', { expectedSubAid: record.sub_aid }).statement) {
|
|
833
|
+
record.cleanup_status = 'cleaned';
|
|
834
|
+
await this.save(handle);
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
const authorization = this.cleanupAuthorization(record);
|
|
838
|
+
if (!authorization || record.cleanup_authorization_id && authorization.operation_id !== record.cleanup_authorization_id) {
|
|
839
|
+
if (record.cleanup_status !== 'action_required') {
|
|
840
|
+
record.cleanup_status = 'action_required';
|
|
841
|
+
await this.save(handle);
|
|
842
|
+
this.emit(record, 'ownership.action_required');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
record.cleanup_authorization_id = authorization.operation_id;
|
|
847
|
+
await this.save(handle);
|
|
848
|
+
await this.deps.files.revokeOwneraid(this.fileTask(handle));
|
|
849
|
+
record.cleanup_status = 'cleaned';
|
|
850
|
+
this.enqueue(record, record.owner_aid, 'sub.cleaned');
|
|
851
|
+
await this.save(handle);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (this.local() === record.owner_aid && !record.files.cleanup?.deleted)
|
|
856
|
+
await this.deps.files.cleanupOwnerFile(this.fileTask(handle));
|
|
857
|
+
}
|
|
858
|
+
emit(record, event, extra = {}) {
|
|
859
|
+
const payload = { ...ownershipTaskResult(record), local_roles: [...record.local_roles], ...extra };
|
|
860
|
+
if (event === 'ownership.request')
|
|
861
|
+
payload.requester_aid = record.initiator_aid;
|
|
862
|
+
if (record.verified_relation_version !== undefined)
|
|
863
|
+
payload.relation_version = record.verified_relation_version;
|
|
864
|
+
queueMicrotask(() => { try {
|
|
865
|
+
const pending = this.deps.emit(event, payload);
|
|
866
|
+
if (pending)
|
|
867
|
+
void pending.catch(() => { });
|
|
868
|
+
}
|
|
869
|
+
catch { } });
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
//# sourceMappingURL=ownership-workflow.js.map
|