@aetheros/kernel-runtime 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -3
- package/src/agent-memory.js +4 -2
- package/src/audit-anchors.js +124 -0
- package/src/audit.js +2 -0
- package/src/authenticated-connectors.js +233 -3
- package/src/budgets.js +226 -0
- package/src/claims-conformance.js +83 -0
- package/src/claims.js +64 -0
- package/src/effect-journal.js +153 -1
- package/src/encrypted-state.js +63 -1
- package/src/external-identity.js +59 -0
- package/src/index.js +4 -1
- package/src/lock-inspection.js +39 -0
- package/src/process-identity.js +82 -0
- package/src/retry-capability.js +35 -0
- package/src/storage-conformance.js +81 -0
- package/src/storage.js +87 -0
- package/src/transactions.js +78 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aetheros/kernel-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,10 +23,17 @@
|
|
|
23
23
|
"./guarded-restore": "./src/guarded-restore.js",
|
|
24
24
|
"./envelope-migrations": "./src/envelope-migrations.js",
|
|
25
25
|
"./agent-memory": "./src/agent-memory.js",
|
|
26
|
-
"./effect-journal": "./src/effect-journal.js"
|
|
26
|
+
"./effect-journal": "./src/effect-journal.js",
|
|
27
|
+
"./budgets": "./src/budgets.js",
|
|
28
|
+
"./lock-inspection": "./src/lock-inspection.js",
|
|
29
|
+
"./process-identity": "./src/process-identity.js",
|
|
30
|
+
"./claims": "./src/claims.js",
|
|
31
|
+
"./claims-conformance": "./src/claims-conformance.js",
|
|
32
|
+
"./storage": "./src/storage.js",
|
|
33
|
+
"./storage-conformance": "./src/storage-conformance.js"
|
|
27
34
|
},
|
|
28
35
|
"scripts": {
|
|
29
|
-
"check": "node --check src/index.js && node --check src/transactions.js && node --check src/migrations.js && node --check src/audit.js && node --check src/connectors.js && node --check src/backups.js && node --check src/encrypted-backups.js && node --check src/authenticated-connectors.js && node --check src/authenticated-audit.js && node --check src/release-signatures.js && node --check src/encrypted-state.js && node --check src/canonical-json.js && node --check src/guarded-restore.js && node --check src/envelope-migrations.js && node --check src/agent-memory.js && node --check src/effect-journal.js",
|
|
36
|
+
"check": "node --check src/index.js && node --check src/transactions.js && node --check src/migrations.js && node --check src/audit.js && node --check src/connectors.js && node --check src/backups.js && node --check src/encrypted-backups.js && node --check src/authenticated-connectors.js && node --check src/authenticated-audit.js && node --check src/release-signatures.js && node --check src/encrypted-state.js && node --check src/canonical-json.js && node --check src/guarded-restore.js && node --check src/envelope-migrations.js && node --check src/agent-memory.js && node --check src/effect-journal.js && node --check src/budgets.js && node --check src/lock-inspection.js && node --check src/process-identity.js && node --check src/claims.js && node --check src/claims-conformance.js && node --check src/storage.js && node --check src/storage-conformance.js",
|
|
30
37
|
"test": "node --test test/*.test.mjs"
|
|
31
38
|
},
|
|
32
39
|
"files": [
|
package/src/agent-memory.js
CHANGED
|
@@ -26,9 +26,11 @@ function checkedMemory(value) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Dedicated encrypted tenant memory. Hosts explicitly authorize every agent/shared scope. */
|
|
29
|
-
export function createEncryptedAgentMemory(path, authority, {tenantId, key, keyId, readKeys, authorize} = {}) {
|
|
29
|
+
export function createEncryptedAgentMemory(path, authority, {tenantId, key, keyId, readKeys, keyProvider, authorize} = {}) {
|
|
30
30
|
if (!(authority instanceof TokenAuthority) || typeof tenantId !== 'string' || !tenantId.trim() || typeof authorize !== 'function') throw new TypeError('Tenant authority and explicit memory authorization policy required');
|
|
31
|
-
|
|
31
|
+
if (keyProvider !== undefined && [key, keyId, readKeys].some(value => value !== undefined)) throw new TypeError('Select static keys or a host key provider');
|
|
32
|
+
const store = createEncryptedStateStore(path, keyProvider === undefined
|
|
33
|
+
? {tenantId, key, keyId, readKeys} : {tenantId, keyProvider});
|
|
32
34
|
async function allow(token, operation, agentId) {
|
|
33
35
|
const authenticate = () => authority.authenticate(token, {tenantId, permission: operation === 'get' || operation === 'list' ? 'read' : 'write'});
|
|
34
36
|
const identity = authenticate();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {FileAuditLog} from './audit.js';
|
|
3
|
+
import {TransactionalStateStore} from './transactions.js';
|
|
4
|
+
import {canonicalJson} from './canonical-json.js';
|
|
5
|
+
|
|
6
|
+
const DOMAIN='aetheros.audit-anchor.v1';
|
|
7
|
+
const GENESIS='GENESIS';
|
|
8
|
+
const copy=value=>structuredClone(value);
|
|
9
|
+
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const text=value=>typeof value==='string'&&value.length>0&&value.length<=256&&/^[A-Za-z0-9_.:-]+$/.test(value);
|
|
11
|
+
const digest=value=>value===GENESIS||typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
|
|
12
|
+
const exact=(value,keys)=>value&&Object.getPrototypeOf(value)===Object.prototype&&Object.keys(value).sort().join(',')===[...keys].sort().join(',');
|
|
13
|
+
const fail=()=>{throw new Error('Audit anchor operation blocked');};
|
|
14
|
+
const statementKeys=['domain','streamId','sinkId','keyId','sequence','previousAnchor','startHash','tailHash','count'];
|
|
15
|
+
const bytes=statement=>Buffer.from(canonicalJson(statement),'utf8');
|
|
16
|
+
const anchorDigest=envelope=>hash(canonicalJson(envelope));
|
|
17
|
+
function validate(envelope,{streamId,sinkId}) {
|
|
18
|
+
if(!exact(envelope,['statement','signature'])||typeof envelope.signature!=='string'||!envelope.signature.length||envelope.signature.length>16384)fail();
|
|
19
|
+
const s=envelope.statement;
|
|
20
|
+
if(!exact(s,statementKeys)||s.domain!==DOMAIN||s.streamId!==streamId||s.sinkId!==sinkId||!text(s.keyId)||!Number.isSafeInteger(s.sequence)||s.sequence<1||!digest(s.previousAnchor)||!digest(s.startHash)||!digest(s.tailHash)||s.tailHash===GENESIS||!Number.isSafeInteger(s.count)||s.count<1||s.count>100000)fail();
|
|
21
|
+
if(s.sequence===1&&(s.previousAnchor!==GENESIS||s.startHash!==GENESIS))fail();
|
|
22
|
+
if(s.sequence>1&&(s.previousAnchor===GENESIS||s.startHash===GENESIS))fail();
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
/** Authenticate a detached anchor using explicit host trust; no downloaded key is trusted. */
|
|
26
|
+
export async function verifyAuditAnchor(envelope,{streamId,sinkId,verify}={}) {
|
|
27
|
+
try {
|
|
28
|
+
if(!text(streamId)||!text(sinkId)||typeof verify!=='function')fail();
|
|
29
|
+
const snapshot=copy(envelope),s=validate(snapshot,{streamId,sinkId});
|
|
30
|
+
if(await verify({payload:bytes(s),keyId:s.keyId,signature:snapshot.signature})!==true)fail();
|
|
31
|
+
return {anchorDigest:anchorDigest(snapshot),statement:copy(s)};
|
|
32
|
+
}catch{fail();}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Privileged local-host delivery primitive. Hosts authenticate/authorize all callers. */
|
|
36
|
+
export class FileAuditAnchorDelivery {
|
|
37
|
+
constructor(path,{audit,tenantId,streamId,sinkId,keyId,sign,verify,readCheckpoint,deliver,verifyReceipt,stateStore}={}) {
|
|
38
|
+
if(!(audit instanceof FileAuditLog)||typeof tenantId!=='string'||!tenantId.trim()||![streamId,sinkId,keyId].every(text)||![sign,verify,readCheckpoint,deliver,verifyReceipt].every(x=>typeof x==='function'))throw new TypeError('Explicit audit anchor host configuration required');
|
|
39
|
+
const base=new TransactionalStateStore(path);
|
|
40
|
+
if(base.path===audit.state.path||stateStore!==undefined&&(!(stateStore instanceof TransactionalStateStore)||stateStore.path!==base.path))throw new TypeError('Separate matching anchor state required');
|
|
41
|
+
this.state=stateStore??base;
|
|
42
|
+
this.config=Object.freeze({audit,tenantId,streamId,sinkId,keyId,sign,verify,readCheckpoint,deliver,verifyReceipt});
|
|
43
|
+
}
|
|
44
|
+
stateKey(){return 'security.audit-anchor/'+this.config.streamId;}
|
|
45
|
+
async #checked(current) {
|
|
46
|
+
const c=this.config;
|
|
47
|
+
if(current===null)return {version:1,tenantId:c.tenantId,streamId:c.streamId,sinkId:c.sinkId,committed:null,pending:null};
|
|
48
|
+
if(!exact(current,['version','tenantId','streamId','sinkId','committed','pending'])||current.version!==1||current.tenantId!==c.tenantId||current.streamId!==c.streamId||current.sinkId!==c.sinkId)fail();
|
|
49
|
+
for(const e of [current.committed,current.pending])if(e!==null)await verifyAuditAnchor(e,c);
|
|
50
|
+
if(current.pending!==null){
|
|
51
|
+
const s=current.pending.statement,previous=current.committed;
|
|
52
|
+
if(s.sequence!==(previous?.statement.sequence??0)+1||s.previousAnchor!==(previous?anchorDigest(previous):GENESIS)||s.startHash!==(previous?.statement.tailHash??GENESIS))fail();
|
|
53
|
+
}
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
async #receipt(receipt,envelope) {
|
|
57
|
+
const c=this.config,r=copy(receipt),s=envelope.statement;
|
|
58
|
+
if(!exact(r,['streamId','sinkId','sequence','anchorDigest','proof'])||r.streamId!==c.streamId||r.sinkId!==c.sinkId||r.sequence!==s.sequence||r.anchorDigest!==anchorDigest(envelope)||typeof r.proof!=='string'||!r.proof.length||r.proof.length>16384)fail();
|
|
59
|
+
if(await c.verifyReceipt(copy(r))!==true)fail();
|
|
60
|
+
}
|
|
61
|
+
async #remote(current) {
|
|
62
|
+
const receipt=await this.config.readCheckpoint();
|
|
63
|
+
if(receipt===null){if(current.committed!==null)fail();return false;}
|
|
64
|
+
if(current.pending!==null){
|
|
65
|
+
try{await this.#receipt(receipt,current.pending);return true;}catch{/* May instead be the last committed checkpoint. */}
|
|
66
|
+
}
|
|
67
|
+
if(current.committed===null)fail();
|
|
68
|
+
await this.#receipt(receipt,current.committed);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
async #prepare() {
|
|
72
|
+
const c=this.config;
|
|
73
|
+
return this.state.updateTenant(this.stateKey(),async raw=>{
|
|
74
|
+
const current=await this.#checked(raw);
|
|
75
|
+
// Read authoritative external position before any new signing. Unknown/rollback/fork blocks.
|
|
76
|
+
await this.#remote(current);
|
|
77
|
+
if(current.pending!==null)return current;
|
|
78
|
+
const log=await c.audit.state.getTenant(c.audit.key(c.tenantId));
|
|
79
|
+
const startHash=current.committed?.statement.tailHash??GENESIS;
|
|
80
|
+
if(log===null){if(startHash!==GENESIS)fail();return current;}
|
|
81
|
+
if(!exact(log,['schemaVersion','maxEntries','anchorHash','entries'])||log.schemaVersion!==1||log.maxEntries!==c.audit.maxEntries||!digest(log.anchorHash)||!Array.isArray(log.entries)||log.entries.length>c.audit.maxEntries)fail();
|
|
82
|
+
let previous=log.anchorHash;
|
|
83
|
+
for(const entry of log.entries){
|
|
84
|
+
if(!entry||entry.tenantId!==c.tenantId||entry.previousHash!==previous||(entry.hashVersion!==undefined&&entry.hashVersion!==2))fail();
|
|
85
|
+
const {hash:entryHash,...payload}=entry;
|
|
86
|
+
if(entryHash!==hash(entry.hashVersion===2?canonicalJson(payload):JSON.stringify(payload)))fail();
|
|
87
|
+
previous=entryHash;
|
|
88
|
+
}
|
|
89
|
+
const position=startHash===log.anchorHash?-1:log.entries.findIndex(e=>e.hash===startHash);
|
|
90
|
+
if(startHash!==log.anchorHash&&position<0)fail(); // Retention removed required continuity.
|
|
91
|
+
const entries=log.entries.slice(position+1);
|
|
92
|
+
if(entries.length===0)return current;
|
|
93
|
+
const sequence=(current.committed?.statement.sequence??0)+1;
|
|
94
|
+
if(!Number.isSafeInteger(sequence))fail();
|
|
95
|
+
const statement={domain:DOMAIN,streamId:c.streamId,sinkId:c.sinkId,keyId:c.keyId,sequence,previousAnchor:current.committed?anchorDigest(current.committed):GENESIS,startHash,tailHash:entries.at(-1).hash,count:entries.length};
|
|
96
|
+
const signature=await c.sign({payload:bytes(statement),keyId:c.keyId});
|
|
97
|
+
const pending={statement,signature};
|
|
98
|
+
await verifyAuditAnchor(pending,c);
|
|
99
|
+
current.pending=pending;
|
|
100
|
+
return current;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Explicit one-anchor flush; never a scheduler or audit/connector effect dispatcher. */
|
|
104
|
+
async flush() {
|
|
105
|
+
try {
|
|
106
|
+
const current=await this.#prepare(),pending=current.pending;
|
|
107
|
+
if(pending===null)return {status:'idle',sequence:current.committed?.statement.sequence??0};
|
|
108
|
+
if(!await this.#remote(current)){
|
|
109
|
+
const receipt=await this.config.deliver(copy(pending));
|
|
110
|
+
await this.#receipt(receipt,pending);
|
|
111
|
+
}
|
|
112
|
+
// A receipt asserts durable acceptance; checkpoint lookup must independently agree.
|
|
113
|
+
const remote=await this.config.readCheckpoint();
|
|
114
|
+
await this.#receipt(remote,pending);
|
|
115
|
+
const result=await this.state.updateTenant(this.stateKey(),async raw=>{
|
|
116
|
+
const latest=await this.#checked(raw);
|
|
117
|
+
if(latest.pending===null&&latest.committed!==null&&anchorDigest(latest.committed)===anchorDigest(pending))return latest;
|
|
118
|
+
if(latest.pending===null||anchorDigest(latest.pending)!==anchorDigest(pending))fail();
|
|
119
|
+
latest.committed=latest.pending;latest.pending=null;return latest;
|
|
120
|
+
});
|
|
121
|
+
return {status:'delivered',sequence:result.committed.statement.sequence,anchorDigest:anchorDigest(result.committed)};
|
|
122
|
+
}catch{fail();}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/audit.js
CHANGED
|
@@ -5,6 +5,7 @@ import {ConnectorRegistry} from './connectors.js';
|
|
|
5
5
|
import {FileEffectJournal} from './effect-journal.js';
|
|
6
6
|
import {snapshotEffectBinding} from './effect-binding.js';
|
|
7
7
|
import {canonicalJson} from './canonical-json.js';
|
|
8
|
+
import {snapshotRetryCapability, assertRetryCapabilityTime} from './retry-capability.js';
|
|
8
9
|
|
|
9
10
|
export class ConnectorInvocationError extends Error {
|
|
10
11
|
constructor(code, effectMayHaveOccurred) {
|
|
@@ -19,20 +20,24 @@ export class ConnectorInvocationError extends Error {
|
|
|
19
20
|
* Optional effectJournal enables durable write recovery; without it the legacy
|
|
20
21
|
* invocation contract is unchanged. Journal lock contention fails closed. Hosts
|
|
21
22
|
* keep result receipts free of secrets and enforce Review in authorize.
|
|
22
|
-
*
|
|
23
|
+
* Ordinary invoke blocks ambiguous writes. Explicit retries require verified host contracts.
|
|
23
24
|
*/
|
|
24
25
|
export class AuthenticatedConnectorRegistry {
|
|
25
26
|
#definitions = new Map();
|
|
26
27
|
#catalog = new ConnectorRegistry();
|
|
27
28
|
#authority; #audit; #tenantId; #authorize; #effectJournal; #requireEffectBinding;
|
|
29
|
+
#getRetryCapability; #verifyRetryCapability; #authorizeRetry; #executeRetry; #authorizeRetryRecovery;
|
|
28
30
|
#authorizeClaimRecovery; #authorizeReconciliation; #verifyReconciliation; #authorizeReceiptLookup; #lookupRemoteReceipt;
|
|
29
|
-
constructor(authority, audit, {tenantId, authorize, effectJournal, requireEffectBinding = false, authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery} = {}) {
|
|
31
|
+
constructor(authority, audit, {tenantId, authorize, effectJournal, requireEffectBinding = false, authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery, getRetryCapability, verifyRetryCapability, authorizeRetry, executeRetry, authorizeRetryRecovery} = {}) {
|
|
30
32
|
if (!(authority instanceof TokenAuthority) || !(audit instanceof FileAuditLog)) throw new TypeError('Token authority and file audit log required');
|
|
31
33
|
if (typeof tenantId !== 'string' || !tenantId.trim()) throw new TypeError('Connector tenant required');
|
|
32
34
|
if (authorize !== undefined && typeof authorize !== 'function') throw new TypeError('Invalid connector policy');
|
|
33
35
|
if (effectJournal !== undefined && !(effectJournal instanceof FileEffectJournal)) throw new TypeError('File effect journal required');
|
|
34
36
|
if (typeof requireEffectBinding !== 'boolean' || (requireEffectBinding && !effectJournal)) throw new TypeError('Effect binding requires a journal');
|
|
35
|
-
for (const policy of [authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery]) if (policy !== undefined && typeof policy !== 'function') throw new TypeError('Invalid reconciliation policy');
|
|
37
|
+
for (const policy of [authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery, getRetryCapability, verifyRetryCapability, authorizeRetry, executeRetry, authorizeRetryRecovery]) if (policy !== undefined && typeof policy !== 'function') throw new TypeError('Invalid reconciliation policy');
|
|
38
|
+
this.#authorizeRetryRecovery = authorizeRetryRecovery;
|
|
39
|
+
this.#authorizeRetry = authorizeRetry; this.#executeRetry = executeRetry;
|
|
40
|
+
this.#getRetryCapability = getRetryCapability; this.#verifyRetryCapability = verifyRetryCapability;
|
|
36
41
|
this.#authorizeClaimRecovery = authorizeClaimRecovery;
|
|
37
42
|
this.#authorizeReceiptLookup = authorizeReceiptLookup; this.#lookupRemoteReceipt = lookupRemoteReceipt;
|
|
38
43
|
this.#authorizeReconciliation = authorizeReconciliation; this.#verifyReconciliation = verifyReconciliation;
|
|
@@ -46,6 +51,231 @@ export class AuthenticatedConnectorRegistry {
|
|
|
46
51
|
return this;
|
|
47
52
|
}
|
|
48
53
|
list() { return this.#catalog.list(); }
|
|
54
|
+
/** Inspect a host-declared guarantee. Never authorizes, reserves, dispatches or enables retries. */
|
|
55
|
+
async inspectRetryCapability({token, expectedEntry, input, signal} = {}) {
|
|
56
|
+
const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
|
|
57
|
+
const identity = authenticate(), requestId = randomUUID();
|
|
58
|
+
const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
|
|
59
|
+
tenantId:identity.tenantId, actor:identity.subject, action:'connector.retry-capability', outcome,
|
|
60
|
+
metadata:{requestId, phase},
|
|
61
|
+
}));
|
|
62
|
+
try {
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
if (!this.#effectJournal || !this.#getRetryCapability || !this.#verifyRetryCapability) throw new Error('Retry capability inspection not configured');
|
|
65
|
+
const snapshot = JSON.parse(canonicalJson({expectedEntry, input})), entry = snapshot.expectedEntry;
|
|
66
|
+
if (!entry || entry.tenantId !== identity.tenantId || entry.kind !== 'idempotent-write' ||
|
|
67
|
+
!['dispatched', 'reconciliation_required'].includes(entry.status) || !snapshotEffectBinding(entry.binding) ||
|
|
68
|
+
this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId)?.effect !== 'write') throw new Error('Bound uncertain idempotent effect required');
|
|
69
|
+
const request = {...snapshot, identity:structuredClone(identity)};
|
|
70
|
+
const check = async () => {
|
|
71
|
+
authenticate(); signal?.throwIfAborted();
|
|
72
|
+
const current = await this.#effectJournal.get({tenantId:entry.tenantId, effectKey:entry.effectKey});
|
|
73
|
+
if (canonicalJson(current) !== canonicalJson(entry)) throw new Error('Effect changed');
|
|
74
|
+
authenticate(); signal?.throwIfAborted();
|
|
75
|
+
};
|
|
76
|
+
const declaration = async () => {
|
|
77
|
+
await check();
|
|
78
|
+
const result = snapshotRetryCapability(await this.#getRetryCapability(structuredClone(request)), entry, snapshot.input);
|
|
79
|
+
await check(); assertRetryCapabilityTime(result);
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
const contract = await declaration();
|
|
83
|
+
const verify = async () => {
|
|
84
|
+
// A separate trusted host verifier pins endpoint/account/policy, original-dispatch
|
|
85
|
+
// coverage and remote guarantees. Neither caller nor remote tool can approve itself.
|
|
86
|
+
if (canonicalJson(await declaration()) !== canonicalJson(contract)) throw new Error('Retry capability changed');
|
|
87
|
+
if (await this.#verifyRetryCapability({...structuredClone(request), capability:structuredClone(contract)}) !== true) throw new Error('Remote guarantee unverified');
|
|
88
|
+
await check(); assertRetryCapabilityTime(contract);
|
|
89
|
+
if (canonicalJson(await declaration()) !== canonicalJson(contract)) throw new Error('Retry capability withdrawn');
|
|
90
|
+
};
|
|
91
|
+
await verify();
|
|
92
|
+
await record('success', 'verified-contract-only');
|
|
93
|
+
await verify();
|
|
94
|
+
return {capability:structuredClone(contract), retryAuthorized:false};
|
|
95
|
+
} catch {
|
|
96
|
+
try { await record('failed', 'inspection'); } catch { /* No inspection result grants dispatch. */ }
|
|
97
|
+
throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Reserve one reviewed retry attempt only. No adapter execution or replay permission. */
|
|
101
|
+
async reserveRetry({token, expectedEntry, input, reviewId, decisionId, signal} = {}) {
|
|
102
|
+
const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
|
|
103
|
+
const identity = authenticate(), requestId = randomUUID();
|
|
104
|
+
const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
|
|
105
|
+
tenantId:identity.tenantId, actor:identity.subject, action:'connector.retry-reservation', outcome,
|
|
106
|
+
metadata:{requestId, phase},
|
|
107
|
+
}));
|
|
108
|
+
try {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (!this.#effectJournal || !this.#getRetryCapability || !this.#verifyRetryCapability || !this.#authorizeRetry) throw new Error('Retry reservation not configured');
|
|
111
|
+
const snapshot = JSON.parse(canonicalJson({expectedEntry, input, reviewId, decisionId})), entry = snapshot.expectedEntry;
|
|
112
|
+
if (!entry || entry.tenantId !== identity.tenantId || entry.kind !== 'idempotent-write' ||
|
|
113
|
+
!['dispatched', 'reconciliation_required'].includes(entry.status) || !snapshotEffectBinding(entry.binding) ||
|
|
114
|
+
Object.hasOwn(entry, 'retry') ||
|
|
115
|
+
this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId)?.effect !== 'write' ||
|
|
116
|
+
typeof snapshot.reviewId !== 'string' || !snapshot.reviewId.trim() || snapshot.reviewId === entry.binding.reviewId ||
|
|
117
|
+
typeof snapshot.decisionId !== 'string' || !snapshot.decisionId.trim() || snapshot.decisionId === entry.binding.decisionId) throw new Error('Fresh retry Review required');
|
|
118
|
+
const base = {identity:structuredClone(identity), expectedEntry:entry, input:snapshot.input};
|
|
119
|
+
const check = async () => {
|
|
120
|
+
authenticate(); signal?.throwIfAborted();
|
|
121
|
+
const current = await this.#effectJournal.get({tenantId:entry.tenantId, effectKey:entry.effectKey});
|
|
122
|
+
if (canonicalJson(current) !== canonicalJson(entry)) throw new Error('Effect changed');
|
|
123
|
+
authenticate(); signal?.throwIfAborted();
|
|
124
|
+
};
|
|
125
|
+
const declaration = async () => {
|
|
126
|
+
await check();
|
|
127
|
+
const value = snapshotRetryCapability(await this.#getRetryCapability(structuredClone(base)), entry, snapshot.input);
|
|
128
|
+
await check(); assertRetryCapabilityTime(value);
|
|
129
|
+
return value;
|
|
130
|
+
};
|
|
131
|
+
const capability = await declaration();
|
|
132
|
+
const attempt = {id:randomUUID(), number:1, status:'reserved'};
|
|
133
|
+
const request = {...base, action:'reserve-retry', capability, reviewId:snapshot.reviewId, decisionId:snapshot.decisionId, attempt};
|
|
134
|
+
const guard = async () => {
|
|
135
|
+
await check();
|
|
136
|
+
if (canonicalJson(await declaration()) !== canonicalJson(capability)) throw new Error('Retry capability changed');
|
|
137
|
+
// Independently establish original endpoint/key/request and retention provenance.
|
|
138
|
+
if (await this.#verifyRetryCapability({...structuredClone(base), capability:structuredClone(capability)}) !== true) throw new Error('Remote guarantee unverified');
|
|
139
|
+
await check(); assertRetryCapabilityTime(capability);
|
|
140
|
+
// Host verifies a fresh action/result-bound Review and current authoritative claim.
|
|
141
|
+
if (await this.#authorizeRetry(structuredClone(request)) !== true) throw new Error('Retry Review denied');
|
|
142
|
+
await check();
|
|
143
|
+
if (canonicalJson(await declaration()) !== canonicalJson(capability)) throw new Error('Retry capability withdrawn');
|
|
144
|
+
authenticate(); signal?.throwIfAborted(); assertRetryCapabilityTime(capability);
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
await guard();
|
|
148
|
+
await record('success', 'authorized');
|
|
149
|
+
const updated = await this.#effectJournal.reserveRetryAttempt({expectedEntry:entry, input:snapshot.input, capability,
|
|
150
|
+
reviewId:snapshot.reviewId, decisionId:snapshot.decisionId, subject:identity.subject, attemptId:attempt.id, beforeCommit:guard});
|
|
151
|
+
await record('success', 'reserved');
|
|
152
|
+
authenticate(); signal?.throwIfAborted();
|
|
153
|
+
return {entry:updated, retryAuthorized:false};
|
|
154
|
+
} catch {
|
|
155
|
+
try { await record('failed', 'reservation'); } catch { /* A reservation may be durable: inspect; never redispatch. */ }
|
|
156
|
+
throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Consume one R2 reservation through a trusted host executor. Interrupted attempts never resume here. */
|
|
160
|
+
async executeRetry({token, expectedEntry, input, reviewId, decisionId, signal} = {}) {
|
|
161
|
+
const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
|
|
162
|
+
const identity = authenticate(), requestId = randomUUID();
|
|
163
|
+
const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
|
|
164
|
+
tenantId:identity.tenantId, actor:identity.subject, action:'connector.retry-execution', outcome,
|
|
165
|
+
metadata:{requestId, phase},
|
|
166
|
+
}));
|
|
167
|
+
let dispatchedEntry;
|
|
168
|
+
try {
|
|
169
|
+
signal?.throwIfAborted();
|
|
170
|
+
if (!this.#effectJournal || !this.#getRetryCapability || !this.#verifyRetryCapability || !this.#authorizeRetry || !this.#executeRetry) throw new Error('Retry execution not configured');
|
|
171
|
+
const snapshot = JSON.parse(canonicalJson({expectedEntry, input, reviewId, decisionId})), entry = snapshot.expectedEntry;
|
|
172
|
+
if (!entry || entry.tenantId !== identity.tenantId || entry.kind !== 'idempotent-write' ||
|
|
173
|
+
!['dispatched', 'reconciliation_required'].includes(entry.status) || !snapshotEffectBinding(entry.binding) ||
|
|
174
|
+
entry.retry?.version !== 1 || entry.retry.number !== 1 || entry.retry.status !== 'reserved' ||
|
|
175
|
+
this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId)?.effect !== 'write' ||
|
|
176
|
+
typeof snapshot.reviewId !== 'string' || !snapshot.reviewId.trim() ||
|
|
177
|
+
[entry.binding.reviewId, entry.retry.authorization.reviewId].includes(snapshot.reviewId) ||
|
|
178
|
+
typeof snapshot.decisionId !== 'string' || !snapshot.decisionId.trim() ||
|
|
179
|
+
[entry.binding.decisionId, entry.retry.authorization.decisionId].includes(snapshot.decisionId)) throw new Error('Fresh execution Review and reservation required');
|
|
180
|
+
const capability = snapshotRetryCapability(entry.retry.authorization.capability, entry, snapshot.input);
|
|
181
|
+
const base = {identity:structuredClone(identity), expectedEntry:entry, input:snapshot.input};
|
|
182
|
+
const request = {...base, action:'execute-retry', capability, reviewId:snapshot.reviewId, decisionId:snapshot.decisionId,
|
|
183
|
+
attempt:{id:entry.retry.id, number:1, status:'reserved'}};
|
|
184
|
+
const guard = async (expected = entry) => {
|
|
185
|
+
const check = async () => {
|
|
186
|
+
authenticate(); signal?.throwIfAborted();
|
|
187
|
+
if (canonicalJson(await this.#effectJournal.get(entry)) !== canonicalJson(expected)) throw new Error('Effect changed');
|
|
188
|
+
authenticate(); signal?.throwIfAborted();
|
|
189
|
+
};
|
|
190
|
+
const declaration = async () => {
|
|
191
|
+
const value = snapshotRetryCapability(await this.#getRetryCapability(structuredClone(base)), entry, snapshot.input);
|
|
192
|
+
if (canonicalJson(value) !== canonicalJson(capability)) throw new Error('Retry capability changed');
|
|
193
|
+
await check();
|
|
194
|
+
};
|
|
195
|
+
await check(); await declaration();
|
|
196
|
+
if (await this.#verifyRetryCapability({...structuredClone(base), capability:structuredClone(capability)}) !== true) throw new Error('Remote guarantee unverified');
|
|
197
|
+
await check(); assertRetryCapabilityTime(capability);
|
|
198
|
+
// Host verifies this execution action, exact reservation/result, current grants and claim generation.
|
|
199
|
+
if (await this.#authorizeRetry(structuredClone(request)) !== true) throw new Error('Retry execution denied');
|
|
200
|
+
await check(); await declaration();
|
|
201
|
+
authenticate(); signal?.throwIfAborted(); assertRetryCapabilityTime(capability);
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
await guard();
|
|
205
|
+
await record('success', 'authorized');
|
|
206
|
+
dispatchedEntry = await this.#effectJournal.transitionRetryAttempt({expectedEntry:entry, target:'dispatched', input:snapshot.input,
|
|
207
|
+
reviewId:snapshot.reviewId, decisionId:snapshot.decisionId, subject:identity.subject, beforeCommit:() => guard()});
|
|
208
|
+
await record('success', 'dispatch-intent');
|
|
209
|
+
await guard(dispatchedEntry);
|
|
210
|
+
// No await between the final synchronous checks and executor entry. Host must route
|
|
211
|
+
// using the pinned endpoint/account/key contract and enforce it at remote admission.
|
|
212
|
+
authenticate(); signal?.throwIfAborted(); assertRetryCapabilityTime(capability);
|
|
213
|
+
const pending = this.#executeRetry({...structuredClone(request), actor:entry.subject, tenantId:entry.tenantId,
|
|
214
|
+
connectorId:entry.connectorId, operationId:entry.operationId, idempotencyKey:entry.effectKey,
|
|
215
|
+
binding:structuredClone(entry.binding), ...(signal && {signal})});
|
|
216
|
+
const result = JSON.parse(canonicalJson(await pending));
|
|
217
|
+
authenticate(); signal?.throwIfAborted();
|
|
218
|
+
await this.#effectJournal.transitionRetryAttempt({expectedEntry:dispatchedEntry, target:'confirmed',
|
|
219
|
+
receipt:{source:'authenticated-connector-result-v1', result}, beforeCommit:async () => {
|
|
220
|
+
authenticate(); signal?.throwIfAborted(); return true;
|
|
221
|
+
}});
|
|
222
|
+
await record('success', 'confirmed');
|
|
223
|
+
authenticate(); signal?.throwIfAborted();
|
|
224
|
+
return structuredClone(result);
|
|
225
|
+
} catch {
|
|
226
|
+
if (dispatchedEntry) {
|
|
227
|
+
try { await this.#effectJournal.transitionRetryAttempt({expectedEntry:dispatchedEntry, target:'reconciliation_required', beforeCommit:async () => true}); }
|
|
228
|
+
catch { /* Preserve concurrent reconciliation, confirmed receipts and unavailable stores. */ }
|
|
229
|
+
}
|
|
230
|
+
try { await record('failed', 'execution'); } catch { /* Inspect journal; never infer that a failed response permits redispatch. */ }
|
|
231
|
+
throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Retire an interrupted retry under fresh Review. No dispatch, lookup or confirmation. */
|
|
235
|
+
async recoverRetry({token, expectedEntry, reviewId, decisionId, signal} = {}) {
|
|
236
|
+
const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
|
|
237
|
+
const identity = authenticate(), requestId = randomUUID();
|
|
238
|
+
const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
|
|
239
|
+
tenantId:identity.tenantId, actor:identity.subject, action:'connector.retry-recovery', outcome,
|
|
240
|
+
metadata:{requestId, phase},
|
|
241
|
+
}));
|
|
242
|
+
try {
|
|
243
|
+
signal?.throwIfAborted();
|
|
244
|
+
if (!this.#effectJournal || !this.#authorizeRetryRecovery) throw new Error('Retry recovery not configured');
|
|
245
|
+
const snapshot = JSON.parse(canonicalJson({expectedEntry, reviewId, decisionId})), entry = snapshot.expectedEntry;
|
|
246
|
+
if (!entry || entry.tenantId !== identity.tenantId || entry.kind !== 'idempotent-write' ||
|
|
247
|
+
!['dispatched', 'reconciliation_required'].includes(entry.status) || !snapshotEffectBinding(entry.binding) ||
|
|
248
|
+
entry.retry?.version !== 1 || entry.retry.number !== 1 ||
|
|
249
|
+
!['reserved', 'dispatched', 'reconciliation_required'].includes(entry.retry.status) || Object.hasOwn(entry.retry, 'recovery') ||
|
|
250
|
+
this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId)?.effect !== 'write' ||
|
|
251
|
+
typeof snapshot.reviewId !== 'string' || !snapshot.reviewId.trim() ||
|
|
252
|
+
[entry.binding.reviewId, entry.retry.authorization?.reviewId, entry.retry.execution?.reviewId].includes(snapshot.reviewId) ||
|
|
253
|
+
typeof snapshot.decisionId !== 'string' || !snapshot.decisionId.trim() ||
|
|
254
|
+
[entry.binding.decisionId, entry.retry.authorization?.decisionId, entry.retry.execution?.decisionId].includes(snapshot.decisionId)) throw new Error('Fresh recovery Review required');
|
|
255
|
+
const request = {...snapshot, identity:structuredClone(identity), action:'recover-retry'};
|
|
256
|
+
const check = async () => {
|
|
257
|
+
authenticate(); signal?.throwIfAborted();
|
|
258
|
+
if (canonicalJson(await this.#effectJournal.get(entry)) !== canonicalJson(entry)) throw new Error('Effect changed');
|
|
259
|
+
authenticate(); signal?.throwIfAborted();
|
|
260
|
+
};
|
|
261
|
+
const guard = async () => {
|
|
262
|
+
await check();
|
|
263
|
+
// Host checks this exact action/attempt/snapshot, fresh Review, current grants
|
|
264
|
+
// and authoritative claim generation. Recovery grants no execution permission.
|
|
265
|
+
if (await this.#authorizeRetryRecovery(structuredClone(request)) !== true) throw new Error('Retry recovery denied');
|
|
266
|
+
await check(); return true;
|
|
267
|
+
};
|
|
268
|
+
await guard();
|
|
269
|
+
await record('success', 'authorized');
|
|
270
|
+
const updated = await this.#effectJournal.recoverRetryAttempt({...snapshot, subject:identity.subject, beforeCommit:guard});
|
|
271
|
+
await record('success', 'retired');
|
|
272
|
+
authenticate(); signal?.throwIfAborted();
|
|
273
|
+
return {entry:updated, retryAuthorized:false};
|
|
274
|
+
} catch {
|
|
275
|
+
try { await record('failed', 'recovery'); } catch { /* Retirement may be durable; inspect before another action. */ }
|
|
276
|
+
throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
49
279
|
/** Review a prepared-only generation handoff. Never dispatches or recovers an uncertain write. */
|
|
50
280
|
async recoverPreparedClaim({token, expectedEntry, nextGeneration, decisionId, signal} = {}) {
|
|
51
281
|
const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
|