@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4
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/CONFORMANCE.md +1 -1
- package/NOTICE +4 -7
- package/PROVENANCE.json +12 -17
- package/README.md +103 -12
- package/bin/agent-commons.mjs +8 -3
- package/examples/Dockerfile +1 -1
- package/package.json +5 -2
- package/src/carrier.mjs +7 -2
- package/src/channels.mjs +1 -1
- package/src/config.mjs +2 -1
- package/src/continuity.mjs +14 -7
- package/src/governance-scope.mjs +1 -1
- package/src/governance.mjs +4 -2
- package/src/index.d.ts +3 -1
- package/src/pillar.mjs +2 -2
- package/src/profiles.mjs +2 -1
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +25 -12
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/trust.mjs +44 -5
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +12 -14
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +4 -5
- package/src/vendor/pillar/identity/keychain.mjs +51 -2
- package/src/vendor/pillar/net/carrier-client.mjs +29 -7
- package/src/vendor/pillar/net/refusals.mjs +4 -2
- package/test/channels.test.mjs +0 -268
- package/test/cohort.test.mjs +0 -36
- package/test/collaboration.test.mjs +0 -459
- package/test/continuity.test.mjs +0 -229
- package/test/custody.negative.test.mjs +0 -165
- package/test/derivation-parity.test.mjs +0 -19
- package/test/fixtures/derivation-vectors.json +0 -26
- package/test/fixtures/ed25519-small-order.vectors.json +0 -15
- package/test/governance-expected-red.expected-red.mjs +0 -241
- package/test/governance-scope.test.mjs +0 -142
- package/test/governance-t1-t10.test.mjs +0 -477
- package/test/governance.test.mjs +0 -514
- package/test/hold-admission.test.mjs +0 -1185
- package/test/keychain-strict-ed25519.test.mjs +0 -240
- package/test/offline.test.mjs +0 -152
- package/test/runtime-channels.test.mjs +0 -1140
- package/test/runtime.test.mjs +0 -201
- package/test/security-runtime.test.mjs +0 -104
- package/test/strict-ed25519-consumers.test.mjs +0 -234
- package/test/transport.test.mjs +0 -353
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
// Cloud Commons relay: record signatures and hash-chained logs (spec v3.2
|
|
2
|
+
// sections 9.3 "signed records", 9.5, 9.6). Signatures are Ed25519 over JCS(body),
|
|
3
|
+
// verified with the vendored strict-key primitive. Logs chain each event to the
|
|
4
|
+
// previous hash, so an export verifies offline without trusting relay.db.
|
|
5
|
+
|
|
6
|
+
import { createHash, sign as edSign } from "node:crypto";
|
|
7
|
+
import { jcs, Keychain } from "../pillar.mjs";
|
|
8
|
+
import { assertIntegerOnly, RelayError, DECISION_V, APPROVAL_V, VERIFY_V, SECURITY_V, OWNER_CONFIRM_V, COAUTHOR_V,
|
|
9
|
+
FINDING_OPEN_V, FINDING_CLOSE_V, ATTEST_V, measuredFamily } from "./records.mjs";
|
|
10
|
+
import { D2_WINDOW_MS } from "./schema.mjs";
|
|
11
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
12
|
+
import { verifyBytesStrict } from "./auth.mjs";
|
|
13
|
+
|
|
14
|
+
export const GENESIS = "0".repeat(64);
|
|
15
|
+
export const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
|
|
16
|
+
|
|
17
|
+
/** A verdict's `prev`: the sha256 of the signer's previous signed body (stored as JCS text) on the same decision. */
|
|
18
|
+
export const verdictPrev = (bodyText) => sha256Hex(Buffer.from(bodyText, "utf8"));
|
|
19
|
+
|
|
20
|
+
export function canonicalBytes(body) {
|
|
21
|
+
assertIntegerOnly(body);
|
|
22
|
+
return Buffer.from(jcs(body), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** signer: a generated identity object (has privateKey) or a loaded Keychain (has sign()). This module mints no keys. */
|
|
26
|
+
export function signBody(signer, body) {
|
|
27
|
+
const bytes = canonicalBytes(body);
|
|
28
|
+
const sig = typeof signer?.sign === "function" ? signer.sign(bytes) : edSign(null, bytes, signer.privateKey);
|
|
29
|
+
return Buffer.from(sig).toString("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyBody(publicKeyHex, body, sigHex) {
|
|
33
|
+
if (typeof sigHex !== "string" || !/^[0-9a-f]{128}$/.test(sigHex)) return false;
|
|
34
|
+
let bytes;
|
|
35
|
+
try { bytes = canonicalBytes(body); } catch { return false; }
|
|
36
|
+
return Keychain.verifyDetached(publicKeyHex, bytes, Buffer.from(sigHex, "hex"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LOG_NAMES = new Set(["decision", "board", "security", "enrollment", "mailbox"]);
|
|
40
|
+
/** Every hash-chained log; the relay-signed export head commits to each of them (9.3, C2). */
|
|
41
|
+
export const RELAY_LOGS = Object.freeze([...LOG_NAMES].sort());
|
|
42
|
+
const linkHash = (prev, log, seq, at, event) => sha256Hex(`${prev}\n${jcs({ log, seq, at, event })}`);
|
|
43
|
+
|
|
44
|
+
/** Append one event to a named log. Call inside the caller's transaction. */
|
|
45
|
+
export function appendLog(db, log, event, at) {
|
|
46
|
+
if (!LOG_NAMES.has(log)) throw new RelayError("unknown-log", 500, log);
|
|
47
|
+
if (!Number.isSafeInteger(at)) throw new RelayError("invalid-time", 500);
|
|
48
|
+
assertIntegerOnly(event, "event");
|
|
49
|
+
const last = db.prepare("SELECT seq, hash FROM logs WHERE log = ? ORDER BY seq DESC LIMIT 1").get(log);
|
|
50
|
+
const seq = (last?.seq ?? 0) + 1, prev = last?.hash ?? GENESIS;
|
|
51
|
+
const hash = linkHash(prev, log, seq, at, event);
|
|
52
|
+
db.prepare("INSERT INTO logs(log, seq, at, event, prev, hash) VALUES (?, ?, ?, ?, ?, ?)").run(log, seq, at, jcs(event), prev, hash);
|
|
53
|
+
return { log, seq, hash };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verify log chains from exported rows; returns the first break per log. */
|
|
57
|
+
export function verifyLogRows(rows) {
|
|
58
|
+
const byLog = new Map();
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
if (!byLog.has(row.log)) byLog.set(row.log, []);
|
|
61
|
+
byLog.get(row.log).push(row);
|
|
62
|
+
}
|
|
63
|
+
const problems = [];
|
|
64
|
+
for (const [log, list] of byLog) {
|
|
65
|
+
list.sort((a, b) => a.seq - b.seq);
|
|
66
|
+
let prev = GENESIS;
|
|
67
|
+
for (let i = 0; i < list.length; i++) {
|
|
68
|
+
const row = list[i];
|
|
69
|
+
let event;
|
|
70
|
+
try { event = JSON.parse(row.event); } catch { problems.push({ log, seq: row.seq, reason: "event-not-json" }); break; }
|
|
71
|
+
if (row.seq !== i + 1) { problems.push({ log, seq: row.seq, reason: "sequence-gap" }); break; }
|
|
72
|
+
if (row.prev !== prev) { problems.push({ log, seq: row.seq, reason: "prev-mismatch" }); break; }
|
|
73
|
+
if (linkHash(prev, log, row.seq, row.at, event) !== row.hash) { problems.push({ log, seq: row.seq, reason: "hash-mismatch" }); break; }
|
|
74
|
+
prev = row.hash;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ok: problems.length === 0, problems };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Everything needed to verify the decision record offline. Contains public data only. */
|
|
81
|
+
export function exportRelay(db) {
|
|
82
|
+
const all = (sql) => db.prepare(sql).all().map(row => ({ ...row }));
|
|
83
|
+
return {
|
|
84
|
+
v: "commons-relay-export/4",
|
|
85
|
+
epoch: db.prepare("SELECT v FROM meta WHERE k = 'epoch'").get().v,
|
|
86
|
+
roster: all(`SELECT f.principal_id, f.kind, f.runtime_family, f.status, k.public_key
|
|
87
|
+
FROM fleet_principals f JOIN keys k ON k.principal_kind = 'fleet' AND k.principal_id = f.principal_id ORDER BY f.principal_id`),
|
|
88
|
+
// The owner-signed roster and policy files as loaded; the verifier re-derives roster, lanes and family map from them.
|
|
89
|
+
signed_files: all("SELECT * FROM signed_files ORDER BY kind, version"),
|
|
90
|
+
lanes: all("SELECT * FROM lanes ORDER BY lane"),
|
|
91
|
+
keys: all("SELECT * FROM keys ORDER BY added_at, public_key"),
|
|
92
|
+
members: all("SELECT member_id, status, role FROM members ORDER BY member_id"),
|
|
93
|
+
decisions: all("SELECT * FROM decisions ORDER BY created_at, id"),
|
|
94
|
+
decision_authors: all("SELECT * FROM decision_authors ORDER BY decision_id, principal"),
|
|
95
|
+
approvals: all("SELECT * FROM approvals ORDER BY signed_at, decision_id, approver, seq"),
|
|
96
|
+
served_by_attestations: all("SELECT * FROM served_by_attestations ORDER BY recorded_at, id"),
|
|
97
|
+
policy_family_map: all("SELECT * FROM policy_family_map ORDER BY policy_version, pattern"),
|
|
98
|
+
submissions: all("SELECT * FROM submissions ORDER BY submission_id"),
|
|
99
|
+
verify_results: all("SELECT * FROM verify_results ORDER BY at"),
|
|
100
|
+
security_approvals: all("SELECT * FROM security_approvals ORDER BY at"),
|
|
101
|
+
coauthors: all("SELECT * FROM coauthors ORDER BY at"),
|
|
102
|
+
owner_record_outbox: all("SELECT * FROM owner_record_outbox ORDER BY created_at, id"),
|
|
103
|
+
owner_record_confirmations: all("SELECT * FROM owner_record_confirmations ORDER BY at"),
|
|
104
|
+
security_findings: all("SELECT * FROM security_findings ORDER BY opened_at, id"),
|
|
105
|
+
invites: all("SELECT * FROM invites ORDER BY created_at, invite_id"),
|
|
106
|
+
invite_details: all("SELECT * FROM invite_details ORDER BY invite_id"),
|
|
107
|
+
enrollments: all("SELECT * FROM enrollments ORDER BY spent_at, id"),
|
|
108
|
+
rebind_ends: all("SELECT * FROM rebind_ends ORDER BY at, invite_id"),
|
|
109
|
+
// Derived state the reducer acts on, so the verifier can tie each row to its decision.
|
|
110
|
+
roles: all("SELECT * FROM roles ORDER BY member_id, seq"),
|
|
111
|
+
suspensions: all("SELECT * FROM suspensions ORDER BY started_at, id"),
|
|
112
|
+
contributions: all("SELECT * FROM contributions ORDER BY accepted_at, submission_id"),
|
|
113
|
+
logs: all("SELECT * FROM logs ORDER BY log, seq"),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The role a role-granting decision gives (the roles relationship check).
|
|
118
|
+
const ROLE_GRANT = { "enroll.confirm": () => "C0", "role.c1": () => "C1", "role.change": (body) => body.args?.role };
|
|
119
|
+
const DONE = ["effective", "reversed"]; // states a decision reaches only by taking effect
|
|
120
|
+
/** SQLite GLOB for the policy's family-map patterns (the policy loader admits only letters, digits, . _ - * ?). */
|
|
121
|
+
const globMatch = (pattern, text) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "s").test(text);
|
|
122
|
+
const WINDOW_BACK_MS = 300000, WINDOW_AHEAD_MS = 60000, ENROLLMENT_TTL = 259200000; // 6.1; 5.3
|
|
123
|
+
const P1 = ["enroll.confirm", "rekey.confirm"];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Offline check of an export against a roster the verifier already trusts (the owner-signed
|
|
127
|
+
* roster, never the export's own copy). Spec 9.3 "Export verification": v3.3 (C2,
|
|
128
|
+
* security review D-C1/D-C3a), v3.3.2 (S1-S5, E-3, settlement) and v3.4 (the D items). It trusts no
|
|
129
|
+
* column it can derive from a signed body, and it checks the relationships between rows:
|
|
130
|
+
* - signatures and log chains; each record's acceptance time t is its log event's time, the
|
|
131
|
+
* signer's key was valid at t (added_at <= t < revoked_at), and body.at lies in the 6.1 window of t;
|
|
132
|
+
* - columns equal their signed bodies (decisions incl. lane, approvals, verify results with passed
|
|
133
|
+
* from the signed exit codes, security approvals, co-authors, owner confirmations, findings by the
|
|
134
|
+
* security principal, attestations' signed fields; relay-derived policy_version and recorded_at);
|
|
135
|
+
* - verdict chains, the implicit creator approval, decision_authors from signed declarations,
|
|
136
|
+
* an owner veto behind every reversed or vetoed decision, rows <-> decision-log events both ways;
|
|
137
|
+
* - settlement: each effective D1, D1R and D2 re-checked as of its decision.effective event;
|
|
138
|
+
* - fleet_principals, lanes and policy_family_map re-derived from the owner-signed files;
|
|
139
|
+
* - roles replayed (veto re-projections), members' role and status re-derived (no member active
|
|
140
|
+
* without an active key), suspensions, invites (invites.decision_id), enrollments,
|
|
141
|
+
* rebind_ends and contributions tied to the decisions that justify them.
|
|
142
|
+
*/
|
|
143
|
+
export function verifyExport(exp, trustedRoster, { relayPublicKey, aud } = {}) {
|
|
144
|
+
const trusted = new Map(trustedRoster.map(p => [p.principal_id, p]));
|
|
145
|
+
const problems = [...verifyLogRows(exp.logs).problems];
|
|
146
|
+
const flag = (kind, id, reason, extra = {}) => problems.push({ kind, id, reason, ...extra });
|
|
147
|
+
for (const table of ["keys", "signed_files", "lanes", "members", "invites", "rebind_ends", "suspensions", "roles"])
|
|
148
|
+
if (!Array.isArray(exp[table])) flag("export", table, "missing-table");
|
|
149
|
+
const parse = (kind, id, text) => {
|
|
150
|
+
try { const body = JSON.parse(text); if (body && typeof body === "object" && !Array.isArray(body)) return body; } catch { /* reported below */ }
|
|
151
|
+
flag(kind, id, "body-not-json");
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
const kindOf = (by) => trusted.get(by)?.kind;
|
|
155
|
+
|
|
156
|
+
// --- the hash-chained logs, as ordered event lists ----------------------------------
|
|
157
|
+
const logOf = (name) => (exp.logs ?? []).filter(l => l.log === name).sort((x, y) => x.seq - y.seq)
|
|
158
|
+
.map(l => { try { return { ...JSON.parse(l.event), _seq: l.seq, _at: l.at }; } catch { return null; } }).filter(Boolean);
|
|
159
|
+
const events = logOf("decision"), security = logOf("security");
|
|
160
|
+
const firstEvent = (list, pred) => list.find(pred) ?? null;
|
|
161
|
+
|
|
162
|
+
const keysByPk = new Map((exp.keys ?? []).map(k => [k.public_key, k]));
|
|
163
|
+
/** The signer's key was the one bound to it and valid at t (C2 key history: the window, never its current status). */
|
|
164
|
+
const keyValidAt = (by, t) => {
|
|
165
|
+
const k = keysByPk.get(trusted.get(by)?.public_key);
|
|
166
|
+
return !!k && k.principal_kind === "fleet" && k.principal_id === by && k.added_at <= t && (k.revoked_at === null || t < k.revoked_at);
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Verify a signed row: signer, signature, acceptance time t (= its log event's time when one is
|
|
170
|
+
* given), key window, the 6.1 window of body.at, version, then each [field, column, signed value].
|
|
171
|
+
*/
|
|
172
|
+
const signedRow = (kind, id, text, signer, sig, version, t, event, columns) => {
|
|
173
|
+
const body = parse(kind, id, text);
|
|
174
|
+
if (!body) return null;
|
|
175
|
+
const by = signer ?? body.by;
|
|
176
|
+
if (body.by !== by) { flag(kind, id, "signer-mismatch"); return null; }
|
|
177
|
+
const key = trusted.get(by)?.public_key;
|
|
178
|
+
if (!key || !verifyBody(key, body, sig)) { flag(kind, id, "bad-signature", { by }); return null; }
|
|
179
|
+
if (event !== undefined && (!event || event._at !== t)) flag(kind, id, "acceptance-time-mismatch");
|
|
180
|
+
if (!keyValidAt(by, t)) flag(kind, id, "key-not-valid-at-signing", { by });
|
|
181
|
+
if (!Number.isSafeInteger(body.at) || body.at < t - WINDOW_BACK_MS || body.at > t + WINDOW_AHEAD_MS) flag(kind, id, "record-time-outside-window");
|
|
182
|
+
if (body.v !== version) flag(kind, id, "record-version", { v: body.v });
|
|
183
|
+
for (const [field, column, value] of columns(body)) if (column !== value) flag(kind, id, "column-mismatch", { field });
|
|
184
|
+
return body;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// --- owner-signed roster and policy files: re-derive fleet_principals, lanes, family map (E-3) ---
|
|
188
|
+
const owner = trustedRoster.find(p => p.kind === "owner");
|
|
189
|
+
const files = { roster: [], policy: [] };
|
|
190
|
+
for (const f of exp.signed_files ?? []) {
|
|
191
|
+
const body = parse("signed-file", `${f.kind}/${f.version}`, f.body);
|
|
192
|
+
if (!body) continue;
|
|
193
|
+
if (!owner || !verifyBody(owner.public_key, body, f.sig) || body.version !== f.version) { flag("signed-file", `${f.kind}/${f.version}`, "bad-signature"); continue; }
|
|
194
|
+
const ev = firstEvent(security, e => e.kind === `${f.kind}.loaded` && e.version === f.version);
|
|
195
|
+
if (!ev || ev._at !== f.loaded_at) flag("signed-file", `${f.kind}/${f.version}`, "log-mismatch");
|
|
196
|
+
(files[f.kind] ?? []).push({ ...f, body });
|
|
197
|
+
}
|
|
198
|
+
for (const list of Object.values(files)) list.sort((x, y) => x.version - y.version);
|
|
199
|
+
if (files.roster.length) {
|
|
200
|
+
const derived = new Map();
|
|
201
|
+
for (const f of files.roster) {
|
|
202
|
+
for (const [id, p] of derived) if (!f.body.principals.some(q => q.principal_id === id)) p.status = "retired";
|
|
203
|
+
for (const p of f.body.principals) if (!derived.has(p.principal_id) || derived.get(p.principal_id).status === "active")
|
|
204
|
+
derived.set(p.principal_id, { principal_id: p.principal_id, kind: p.kind, runtime_family: p.runtime_family, status: "active", public_key: p.public_key });
|
|
205
|
+
}
|
|
206
|
+
const rows = [...derived.values()].sort((x, y) => (x.principal_id < y.principal_id ? -1 : 1));
|
|
207
|
+
if (JSON.stringify(rows) !== JSON.stringify(exp.roster ?? [])) flag("roster", "fleet_principals", "derived-mismatch");
|
|
208
|
+
}
|
|
209
|
+
const policyAt = (t) => { // the policy versions in force at time t (several when loaded at the same instant)
|
|
210
|
+
const loaded = files.policy.filter(f => f.loaded_at <= t);
|
|
211
|
+
const last = loaded.at(-1)?.loaded_at;
|
|
212
|
+
return loaded.filter(f => f.loaded_at === last);
|
|
213
|
+
};
|
|
214
|
+
if (files.policy.length) {
|
|
215
|
+
const lanes = new Map();
|
|
216
|
+
for (const f of files.policy) for (const l of f.body.lanes) lanes.set(l.lane, l.owner_seat);
|
|
217
|
+
const rows = [...lanes].map(([lane, owner_seat]) => ({ lane, owner_seat })).sort((x, y) => (x.lane < y.lane ? -1 : 1));
|
|
218
|
+
if (JSON.stringify(rows) !== JSON.stringify(exp.lanes ?? [])) flag("lanes", "lanes", "derived-mismatch");
|
|
219
|
+
const map = files.policy.flatMap(f => f.body.family_map.map(e => ({ policy_version: f.version, pattern: e.pattern, family: e.family })))
|
|
220
|
+
.sort((x, y) => x.policy_version - y.policy_version || (x.pattern < y.pattern ? -1 : x.pattern > y.pattern ? 1 : 0));
|
|
221
|
+
if (JSON.stringify(map) !== JSON.stringify(exp.policy_family_map ?? [])) flag("policy_family_map", "policy_family_map", "derived-mismatch");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- decisions, owner lines, verdicts ---------------------------------------------
|
|
225
|
+
const created = new Map(events.filter(e => e.kind === "decision.created").map(e => [e.id, e]));
|
|
226
|
+
const decisions = new Map();
|
|
227
|
+
for (const d of exp.decisions) {
|
|
228
|
+
const body = signedRow("decision", d.id, d.body, null, d.sig, DECISION_V, d.created_at, created.get(d.id) ?? null, b => [
|
|
229
|
+
["id", d.id, b.id], ["class", d.class, b.class], ["action", d.action, b.action], ["subject", d.subject, b.subject],
|
|
230
|
+
["lane", d.lane, b.lane ?? null], ["sponsor", d.sponsor, b.sponsor ?? null], ["served_by", d.served_by, b.served_by]]);
|
|
231
|
+
if (!body) continue;
|
|
232
|
+
decisions.set(d.id, { row: d, body });
|
|
233
|
+
if (["veto", "unfreeze"].includes(d.action) && kindOf(body.by) !== "owner") flag("decision", d.id, "owner-record-not-by-owner");
|
|
234
|
+
}
|
|
235
|
+
const confirmations = new Map();
|
|
236
|
+
for (const c of exp.owner_record_confirmations ?? []) {
|
|
237
|
+
const ev = firstEvent(events, e => e.kind === "owner-record.confirm" && e.outbox_id === c.outbox_id);
|
|
238
|
+
const body = signedRow("owner-record-confirm", c.outbox_id, c.body, c.confirmed_by, c.sig, OWNER_CONFIRM_V, c.at, ev, b => [["outbox_id", c.outbox_id, b.outbox_id]]);
|
|
239
|
+
const line = (exp.owner_record_outbox ?? []).find(o => o.id === c.outbox_id);
|
|
240
|
+
if (body && (!line || line.line_sha256 !== body.line_sha256 || line.confirmed_by !== c.confirmed_by || line.confirmed_at !== c.at))
|
|
241
|
+
flag("owner-record-confirm", c.outbox_id, "confirmation-line-mismatch");
|
|
242
|
+
if (body) confirmations.set(c.outbox_id, { ...c, _seq: ev?._seq ?? Infinity });
|
|
243
|
+
}
|
|
244
|
+
for (const o of exp.owner_record_outbox ?? [])
|
|
245
|
+
if (sha256Hex(Buffer.from(`${o.line}\n`, "utf8")) !== o.line_sha256) flag("owner-line", o.id, "line-hash-mismatch");
|
|
246
|
+
const vetoOf = (targetId) => [...decisions.values()].find(v => v.row.class === "OWNER" && v.row.action === "veto" && v.row.subject === targetId && v.row.state === "effective");
|
|
247
|
+
for (const { row: d } of decisions.values()) {
|
|
248
|
+
if (d.owner_posted_at !== null) {
|
|
249
|
+
const line = (exp.owner_record_outbox ?? []).find(o => o.id === d.owner_line_ref);
|
|
250
|
+
if (!line || line.subject !== d.id || line.confirmed_at !== d.owner_posted_at || !confirmations.has(line.id)) flag("decision", d.id, "owner-post-unconfirmed");
|
|
251
|
+
}
|
|
252
|
+
if (d.class === "D2" && d.effect_at !== (d.owner_posted_at === null ? null : d.owner_posted_at + D2_WINDOW_MS)) flag("decision", d.id, "column-mismatch", { field: "effect_at" });
|
|
253
|
+
if (["reversed", "vetoed"].includes(d.state) && !vetoOf(d.id)) flag("decision", d.id, "reversal-without-veto");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const verdictEvents = new Map(events.filter(e => e.kind === "verdict").map(e => [`${e.decision_id}\n${e.by}\n${e.seq}`, e]));
|
|
257
|
+
const chains = new Map();
|
|
258
|
+
for (const a of exp.approvals) {
|
|
259
|
+
const id = `${a.decision_id}/${a.approver}/${a.seq}`;
|
|
260
|
+
const decision = decisions.get(a.decision_id);
|
|
261
|
+
if (!decision) { flag("approval", id, "unknown-decision"); continue; }
|
|
262
|
+
const p = trusted.get(a.approver);
|
|
263
|
+
if (p && "kind" in p && (a.approver_kind !== p.kind || a.approver_family !== (p.runtime_family ?? null))) flag("approval", id, "column-mismatch", { field: "approver_kind/approver_family" });
|
|
264
|
+
if (a.body_sha256 !== sha256Hex(Buffer.from(a.body, "utf8"))) flag("approval", id, "column-mismatch", { field: "body_sha256" });
|
|
265
|
+
const body = parse("approval", id, a.body);
|
|
266
|
+
if (!body) continue;
|
|
267
|
+
if (body.v === DECISION_V) {
|
|
268
|
+
// The implicit approval a D0, D1 or D1R creator gives by creating the decision (S2: the body's creator).
|
|
269
|
+
const { row: d, body: db } = decision;
|
|
270
|
+
if (a.seq !== 1 || a.prev !== null || a.verdict !== "approve" || a.body !== d.body || a.sig !== d.sig || db.by !== a.approver
|
|
271
|
+
|| a.served_by !== db.served_by || a.signed_at !== d.created_at || !["D0", "D1", "D1R"].includes(d.class)) flag("approval", id, "implicit-approval-mismatch");
|
|
272
|
+
} else {
|
|
273
|
+
signedRow("approval", id, a.body, a.approver, a.sig, APPROVAL_V, a.signed_at, verdictEvents.get(`${a.decision_id}\n${a.approver}\n${a.seq}`) ?? null, b => [
|
|
274
|
+
["decision_id", a.decision_id, b.decision_id], ["verdict", a.verdict, b.verdict], ["seq", a.seq, b.seq], ["prev", a.prev, b.prev], ["served_by", a.served_by, b.served_by]]);
|
|
275
|
+
}
|
|
276
|
+
const key = `${a.decision_id}\n${a.approver}`;
|
|
277
|
+
if (!chains.has(key)) chains.set(key, []);
|
|
278
|
+
chains.get(key).push({ a, body });
|
|
279
|
+
}
|
|
280
|
+
for (const { row: d, body } of decisions.values())
|
|
281
|
+
if (["D0", "D1", "D1R"].includes(d.class) && !chains.has(`${d.id}\n${body.by}`)) flag("decision", d.id, "implicit-approval-missing");
|
|
282
|
+
for (const rows of chains.values()) {
|
|
283
|
+
rows.sort((x, y) => x.a.seq - y.a.seq);
|
|
284
|
+
const { row: d, body: db } = decisions.get(rows[0].a.decision_id);
|
|
285
|
+
const creatorVerdict = ["D0", "D1", "D1R"].includes(d.class) && db.by === rows[0].a.approver;
|
|
286
|
+
rows.forEach(({ a, body }, i) => {
|
|
287
|
+
const id = `${a.decision_id}/${a.approver}/${a.seq}`;
|
|
288
|
+
if (a.seq !== i + 1) flag("approval", id, "approval-seq-gap");
|
|
289
|
+
if (i === 0 && creatorVerdict !== (body.v === DECISION_V)) flag("approval", id, "implicit-approval-mismatch");
|
|
290
|
+
if (i > 0 && body.v === DECISION_V) flag("approval", id, "implicit-approval-mismatch");
|
|
291
|
+
if (body.v === APPROVAL_V && body.prev !== (i === 0 ? null : verdictPrev(rows[i - 1].a.body))) flag("approval", id, "approval-prev-mismatch");
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- rows <-> the decision log, both ways; states <-> their transition events -----------
|
|
296
|
+
const transitions = new Map(), reversedAt = new Map(), effectiveEvent = new Map();
|
|
297
|
+
for (const e of events) {
|
|
298
|
+
const m = /^decision\.(effective|lapsed|vetoed|reversed)$/.exec(e.kind);
|
|
299
|
+
if (m) transitions.set(e.id, [...(transitions.get(e.id) ?? []), m[1]]);
|
|
300
|
+
if (e.kind === "decision.reversed") reversedAt.set(e.id, e._seq);
|
|
301
|
+
if (e.kind === "decision.effective") effectiveEvent.set(e.id, e);
|
|
302
|
+
}
|
|
303
|
+
const STATE_LOG = { pending: "", effective: "effective", reversed: "effective,reversed", lapsed: "lapsed", vetoed: "vetoed", withdrawn: "" };
|
|
304
|
+
const rowIds = new Set(exp.decisions.map(d => d.id));
|
|
305
|
+
for (const { row: d, body } of decisions.values()) {
|
|
306
|
+
const e = created.get(d.id);
|
|
307
|
+
if (!e || e.class !== d.class || e.action !== d.action || e.subject !== d.subject || e.by !== body.by || e.served_by !== d.served_by) flag("decision", d.id, "log-mismatch");
|
|
308
|
+
if (STATE_LOG[d.state] !== (transitions.get(d.id) ?? []).join(",")) flag("decision", d.id, "state-log-mismatch", { state: d.state });
|
|
309
|
+
if (d.state === "withdrawn" && !(chains.get(`${d.id}\n${d.sponsor}`) ?? []).some(r => r.a.verdict === "withdraw")) flag("decision", d.id, "state-log-mismatch", { state: d.state });
|
|
310
|
+
}
|
|
311
|
+
for (const id of created.keys()) if (!rowIds.has(id)) flag("decision", id, "row-missing");
|
|
312
|
+
const unmatched = new Map(verdictEvents);
|
|
313
|
+
for (const rows of chains.values()) for (const { a, body } of rows) {
|
|
314
|
+
if (body.v !== APPROVAL_V) continue; // the implicit approval is logged as decision.created
|
|
315
|
+
const key = `${a.decision_id}\n${a.approver}\n${a.seq}`, e = unmatched.get(key);
|
|
316
|
+
if (!e || e.verdict !== a.verdict || e.served_by !== a.served_by) flag("approval", `${a.decision_id}/${a.approver}/${a.seq}`, "log-mismatch");
|
|
317
|
+
unmatched.delete(key);
|
|
318
|
+
}
|
|
319
|
+
for (const e of unmatched.values()) flag("approval", `${e.decision_id}/${e.by}/${e.seq}`, "row-missing");
|
|
320
|
+
|
|
321
|
+
// --- other signed records ------------------------------------------------------------
|
|
322
|
+
for (const v of exp.verify_results) {
|
|
323
|
+
signedRow("verify-result", v.submission_id, v.body, v.verifier, v.sig, VERIFY_V, v.at,
|
|
324
|
+
firstEvent(events, e => e.kind === "verify.result" && e.submission_id === v.submission_id && e.by === v.verifier), b => [
|
|
325
|
+
["submission_id", v.submission_id, b.submission_id], ["patch_sha256", v.patch_sha256, b.patch_sha256], ["served_by", v.served_by, b.served_by],
|
|
326
|
+
["passed", v.passed, Array.isArray(b.exit_codes) && b.exit_codes.length > 0 && b.exit_codes.every(c => c === 0) ? 1 : 0]]);
|
|
327
|
+
}
|
|
328
|
+
for (const s of exp.security_approvals)
|
|
329
|
+
signedRow("security-approval", s.submission_id, s.body, s.reviewer, s.sig, SECURITY_V, s.at,
|
|
330
|
+
firstEvent(events, e => e.kind === "security.approve" && e.submission_id === s.submission_id && e.by === s.reviewer),
|
|
331
|
+
b => [["submission_id", s.submission_id, b.submission_id], ["served_by", s.served_by, b.served_by]]);
|
|
332
|
+
const declared = new Map(); // submission -> principals with a verified co-author declaration
|
|
333
|
+
for (const c of exp.coauthors ?? []) {
|
|
334
|
+
const body = signedRow("coauthor", c.submission_id, c.body, c.principal, c.sig, COAUTHOR_V, c.at,
|
|
335
|
+
firstEvent(events, e => e.kind === "coauthor.declare" && e.submission_id === c.submission_id && e.by === c.principal),
|
|
336
|
+
b => [["submission_id", c.submission_id, b.submission_id], ["served_by", c.served_by, b.served_by]]);
|
|
337
|
+
if (body) declared.set(c.submission_id, [...(declared.get(c.submission_id) ?? []), c.principal]);
|
|
338
|
+
}
|
|
339
|
+
for (const f of exp.security_findings ?? []) {
|
|
340
|
+
// S4, E-1: opened and closed only by a security principal.
|
|
341
|
+
signedRow("finding-open", f.id, f.body, f.opened_by, f.sig, FINDING_OPEN_V, f.opened_at,
|
|
342
|
+
firstEvent(security, e => e.kind === "finding.opened" && e.finding === f.id), b => [["id", f.id, b.id], ["member_id", f.member_id, b.member_id]]);
|
|
343
|
+
if (kindOf(f.opened_by) !== "security") flag("finding-open", f.id, "finding-not-security");
|
|
344
|
+
if (f.closed_at !== null) {
|
|
345
|
+
signedRow("finding-close", f.id, f.close_body, f.closed_by, f.close_sig, FINDING_CLOSE_V, f.closed_at,
|
|
346
|
+
firstEvent(security, e => e.kind === "finding.closed" && e.finding === f.id), b => [["finding_id", f.id, b.finding_id]]);
|
|
347
|
+
if (kindOf(f.closed_by) !== "security") flag("finding-close", f.id, "finding-not-security");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// decision_authors = the submitting member + the signed co-author declarations (V5, C2).
|
|
352
|
+
const authorsOf = new Map();
|
|
353
|
+
for (const r of exp.decision_authors ?? []) {
|
|
354
|
+
if (!decisions.has(r.decision_id)) { flag("decision-author", `${r.decision_id}/${r.principal}`, "unknown-decision"); continue; }
|
|
355
|
+
authorsOf.set(r.decision_id, [...(authorsOf.get(r.decision_id) ?? []), r.principal]);
|
|
356
|
+
}
|
|
357
|
+
for (const { row: d } of decisions.values()) {
|
|
358
|
+
const actual = [...(authorsOf.get(d.id) ?? [])].sort();
|
|
359
|
+
const sub = d.action === "accept" ? (exp.submissions ?? []).find(s => s.submission_id === d.subject) : null;
|
|
360
|
+
const expected = d.action === "accept" ? [...new Set([sub?.member_id, ...(declared.get(d.subject) ?? [])])].filter(Boolean).sort() : [];
|
|
361
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) flag("decision", d.id, "authors-mismatch");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- C5 attestations -----------------------------------------------------------------
|
|
365
|
+
const maps = exp.policy_family_map ?? [];
|
|
366
|
+
const familyOf = (version, model) => {
|
|
367
|
+
const hits = maps.filter(m => m.policy_version === version && globMatch(m.pattern, model));
|
|
368
|
+
return hits.length === 1 ? hits[0].family : null;
|
|
369
|
+
};
|
|
370
|
+
const attestations = [];
|
|
371
|
+
for (const s of exp.served_by_attestations ?? []) {
|
|
372
|
+
const ev = firstEvent(events, e => e.kind === "served_by.attest" && e.id === s.id);
|
|
373
|
+
const body = signedRow("attestation", s.id, s.body, s.measurer, s.sig, ATTEST_V, s.recorded_at, ev, b => [
|
|
374
|
+
["decision_id", s.decision_id, b.target?.decision_id], ["target_by", s.target_by, b.target?.by], ["target_seq", s.target_seq, b.target?.seq],
|
|
375
|
+
["session_id", s.session_id, b.session_id], ["record_uuid", s.record_uuid, b.record_uuid], ["response_model", s.response_model, b.response_model],
|
|
376
|
+
["turn_models", s.turn_models, JSON.stringify(b.turn_models)], ["family", s.family, b.family], ["body_sha256", s.body_sha256, b.body_sha256],
|
|
377
|
+
["tool", s.tool, b.tool], ["tool_sha256", s.tool_sha256, b.tool_sha256], ["measured_at", s.measured_at, b.measured_at]]);
|
|
378
|
+
if (!body) continue;
|
|
379
|
+
// Relay-derived: policy_version is the policy in force at recorded_at (v3.3.2).
|
|
380
|
+
if (files.policy.length && !policyAt(s.recorded_at).some(f => f.version === s.policy_version)) flag("attestation", s.id, "column-mismatch", { field: "policy_version" });
|
|
381
|
+
if (kindOf(s.measurer) !== undefined && kindOf(s.measurer) !== "measurer") flag("attestation", s.id, "attest-not-measurer");
|
|
382
|
+
const target = exp.approvals.find(a => a.decision_id === s.decision_id && a.approver === s.target_by && a.seq === s.target_seq);
|
|
383
|
+
if (!target || target.body_sha256 !== s.body_sha256) flag("attestation", s.id, "attest-target-mismatch");
|
|
384
|
+
const d = decisions.get(s.decision_id);
|
|
385
|
+
if (d && (d.body.by === s.measurer || d.row.sponsor === s.measurer || exp.approvals.some(a => a.decision_id === d.row.id && a.approver === s.measurer)
|
|
386
|
+
|| (authorsOf.get(d.row.id) ?? []).includes(s.measurer))) flag("attestation", s.id, "measurer-conflict");
|
|
387
|
+
let turn = [];
|
|
388
|
+
try { turn = JSON.parse(s.turn_models); } catch { /* flagged below */ }
|
|
389
|
+
if (familyOf(s.policy_version, s.response_model) !== s.family) flag("attestation", s.id, "attest-family-map");
|
|
390
|
+
if (!Array.isArray(turn) || !turn.includes(s.response_model) || turn.some(m => familyOf(s.policy_version, m) !== s.family)) flag("attestation", s.id, "attest-mixed-turn");
|
|
391
|
+
attestations.push({ ...s, _seq: ev?._seq ?? Infinity });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- settlement (v3.3.2): each effective D1, D1R and D2 re-checked as of its effective event ---
|
|
395
|
+
const frozenAt = (seq) => {
|
|
396
|
+
let frozen = false;
|
|
397
|
+
for (const e of events) {
|
|
398
|
+
if (e._seq >= seq) break;
|
|
399
|
+
if (e.kind === "decision.effective" && e.action === "freeze") frozen = true;
|
|
400
|
+
if (e.kind === "decision.effective" && e.action === "unfreeze") frozen = false;
|
|
401
|
+
}
|
|
402
|
+
return frozen;
|
|
403
|
+
};
|
|
404
|
+
for (const { row: d, body } of decisions.values()) {
|
|
405
|
+
if (!["D1", "D1R", "D2"].includes(d.class) || !DONE.includes(d.state)) continue;
|
|
406
|
+
const E = effectiveEvent.get(d.id);
|
|
407
|
+
if (!E) continue; // state-log-mismatch already flagged
|
|
408
|
+
const latest = new Map();
|
|
409
|
+
for (const [key, rows] of chains) {
|
|
410
|
+
if (!key.startsWith(`${d.id}\n`)) continue;
|
|
411
|
+
for (const { a, body: vb } of rows) {
|
|
412
|
+
const seq = vb.v === DECISION_V ? created.get(d.id)?._seq : verdictEvents.get(`${a.decision_id}\n${a.approver}\n${a.seq}`)?._seq;
|
|
413
|
+
if (seq !== undefined && seq < E._seq && (!latest.has(a.approver) || latest.get(a.approver).seq < a.seq)) latest.set(a.approver, a);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const authors = new Set(authorsOf.get(d.id) ?? []);
|
|
417
|
+
const seats = [...latest.values()].filter(a => kindOf(a.approver) === "ac-seat");
|
|
418
|
+
const approvals = seats.filter(a => a.verdict === "approve" && !authors.has(a.approver) && a.approver !== d.sponsor);
|
|
419
|
+
const families = new Set(approvals.map(a => {
|
|
420
|
+
const asserted = measuredFamily(a.served_by);
|
|
421
|
+
return attestations.some(s => s.decision_id === d.id && s.target_by === a.approver && s.target_seq === a.seq && s._seq < E._seq && s.family === asserted) ? asserted : null;
|
|
422
|
+
}).filter(Boolean));
|
|
423
|
+
const blocked = seats.some(a => a.verdict === "reject") || (d.class === "D2" && seats.some(a => a.verdict === "object" && a.approver !== d.sponsor));
|
|
424
|
+
let ok = !blocked && !frozenAt(E._seq) && families.size >= 2;
|
|
425
|
+
if (d.class === "D1") {
|
|
426
|
+
const owners = policyAt(E._at).map(f => f.body.lanes.find(l => l.lane === body.lane)?.owner_seat).filter(Boolean);
|
|
427
|
+
ok = ok && approvals.length >= 2 && (owners.length === 0 || approvals.some(a => owners.includes(a.approver)));
|
|
428
|
+
}
|
|
429
|
+
if (d.class === "D1R" || d.class === "D2") {
|
|
430
|
+
const post = d.owner_line_ref && confirmations.get(d.owner_line_ref);
|
|
431
|
+
ok = ok && !!post && post._seq < E._seq && approvals.length >= (d.class === "D2" ? 3 : 2);
|
|
432
|
+
if (d.class === "D2") ok = ok && d.effect_at !== null && E._at >= d.effect_at;
|
|
433
|
+
}
|
|
434
|
+
if (!ok) flag("decision", d.id, "settlement-mismatch");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- derived rows ----------------------------------------------------------------------
|
|
438
|
+
const justified = (id, actions) => { const d = decisions.get(id); return d && DONE.includes(d.row.state) && actions.includes(d.row.action) ? d : null; };
|
|
439
|
+
const enrollmentOf = (id) => (exp.enrollments ?? []).find(e => e.id === id);
|
|
440
|
+
const inviteOf = (id) => (exp.invites ?? []).find(i => i.invite_id === id);
|
|
441
|
+
const reversedBy = (id) => { // the decision a veto reversed, when `id` is an effective owner veto of a reversed decision
|
|
442
|
+
const v = decisions.get(id);
|
|
443
|
+
if (!v || v.row.class !== "OWNER" || v.row.action !== "veto" || v.row.state !== "effective") return null;
|
|
444
|
+
const t = decisions.get(v.row.subject);
|
|
445
|
+
return t && t.row.state === "reversed" ? t : null;
|
|
446
|
+
};
|
|
447
|
+
/** The member a decision is about: its subject, or the member of the invite / enrollment it cites. */
|
|
448
|
+
const memberOfDecision = (t) => {
|
|
449
|
+
if (t.row.action === "invite.create") return (exp.invites ?? []).find(i => i.decision_id === t.row.id)?.member_id ?? null;
|
|
450
|
+
if (["rekey.confirm", "rebind.confirm"].includes(t.row.action)) return enrollmentOf(t.row.subject)?.member_id ?? null;
|
|
451
|
+
return t.row.subject;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// D-C1 (v3.4.3 9.3): each role row is bound to the logged event
|
|
455
|
+
// that produced it: its grant's decision.effective event, or the reversal.effect role.reprojected of
|
|
456
|
+
// the veto it cites. A decision produces at most one role row (9.3 roles.decision_id UNIQUE), rows
|
|
457
|
+
// follow their events in decision-log order, and a row's time is its event's time. So a row that
|
|
458
|
+
// re-cites an earlier grant cannot be appended to a ledger, before or after a revocation.
|
|
459
|
+
const reprojected = new Map();
|
|
460
|
+
for (const e of events) if (e.kind === "reversal.effect" && e.effect === "role.reprojected") reprojected.set(`${e.veto}\n${e.member_id}`, e);
|
|
461
|
+
const producer = (r) => (ROLE_GRANT[decisions.get(r.decision_id)?.row.action] ? effectiveEvent.get(r.decision_id) : reprojected.get(`${r.decision_id}\n${r.member_id}`)) ?? null;
|
|
462
|
+
const citations = new Map();
|
|
463
|
+
for (const r of exp.roles ?? []) citations.set(r.decision_id, (citations.get(r.decision_id) ?? 0) + 1);
|
|
464
|
+
const rolesByMember = new Map();
|
|
465
|
+
for (const r of exp.roles ?? []) rolesByMember.set(r.member_id, [...(rolesByMember.get(r.member_id) ?? []), r]);
|
|
466
|
+
for (const [member, rows] of rolesByMember) {
|
|
467
|
+
rows.sort((x, y) => x.seq - y.seq);
|
|
468
|
+
rows.forEach((r, i) => {
|
|
469
|
+
const id = `${member}/${r.seq}`;
|
|
470
|
+
if (r.seq !== i + 1) flag("role", id, "role-seq-gap");
|
|
471
|
+
if (citations.get(r.decision_id) > 1) flag("role", id, "role-duplicate-decision");
|
|
472
|
+
const ev = producer(r);
|
|
473
|
+
if (!ev) flag("role", id, "role-event-missing");
|
|
474
|
+
else {
|
|
475
|
+
if (ev._at !== r.at) flag("role", id, "role-time-mismatch");
|
|
476
|
+
const prev = i > 0 ? producer(rows[i - 1]) : null;
|
|
477
|
+
if (prev && !(ev._seq > prev._seq)) flag("role", id, "role-order-mismatch");
|
|
478
|
+
if (ev.kind === "reversal.effect" && ev.to !== r.role) flag("role", id, "role-decision-mismatch");
|
|
479
|
+
}
|
|
480
|
+
const grant = justified(r.decision_id, Object.keys(ROLE_GRANT));
|
|
481
|
+
if (grant) {
|
|
482
|
+
if (grant.row.subject !== member || ROLE_GRANT[grant.row.action](grant.body) !== r.role) flag("role", id, "role-decision-mismatch");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// C3 / D-C3a: a re-projection cites the veto and equals the latest grant still effective
|
|
486
|
+
// when that veto reversed its target (replayed in decision-log order).
|
|
487
|
+
const t = reversedBy(r.decision_id);
|
|
488
|
+
if (!t || t.row.subject !== member || !["role.c1", "role.change"].includes(t.row.action)) { flag("role", id, "role-decision-mismatch"); return; }
|
|
489
|
+
const cut = reversedAt.get(t.row.id);
|
|
490
|
+
const still = rows.slice(0, i).filter(x => {
|
|
491
|
+
const g = justified(x.decision_id, Object.keys(ROLE_GRANT));
|
|
492
|
+
return g && !(reversedAt.has(g.row.id) && reversedAt.get(g.row.id) <= cut);
|
|
493
|
+
});
|
|
494
|
+
if (still.at(-1)?.role !== r.role) flag("role", id, "role-decision-mismatch");
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The 9.7 projection, re-derived from the decisions alone (D-C1; security review: for every status): the
|
|
499
|
+
* role of the latest role grant (enroll.confirm C0, role.c1 C1, role.change) by its decision.effective
|
|
500
|
+
* event, among grants effective before `cut` and not reversed before it. For a revoked member, `cut` is
|
|
501
|
+
* the revoking reversal itself, so the projection is the role at revocation (S5).
|
|
502
|
+
*/
|
|
503
|
+
const projectionAt = (member, cut = Infinity) => {
|
|
504
|
+
let best = null;
|
|
505
|
+
for (const d of decisions.values()) {
|
|
506
|
+
if (!ROLE_GRANT[d.row.action] || d.row.subject !== member || !DONE.includes(d.row.state)) continue;
|
|
507
|
+
const ev = effectiveEvent.get(d.row.id);
|
|
508
|
+
if (!ev || !(ev._seq < cut) || (reversedAt.has(d.row.id) && reversedAt.get(d.row.id) < cut)) continue;
|
|
509
|
+
if (!best || ev._seq > best.ev._seq) best = { d, ev };
|
|
510
|
+
}
|
|
511
|
+
return best ? ROLE_GRANT[best.d.row.action](best.d.body) ?? null : null;
|
|
512
|
+
};
|
|
513
|
+
// D-C1, S5: the members projection is re-derived, never trusted.
|
|
514
|
+
for (const m of exp.members ?? []) {
|
|
515
|
+
const rows = rolesByMember.get(m.member_id) ?? [];
|
|
516
|
+
const enrolls = [...decisions.values()].filter(d => d.row.action === "enroll.confirm" && d.row.subject === m.member_id);
|
|
517
|
+
const own = (exp.enrollments ?? []).find(e => e.kind === "enroll" && e.member_id === m.member_id);
|
|
518
|
+
const ownInvite = own && decisions.get(inviteOf(own.invite_id)?.decision_id);
|
|
519
|
+
// The reversals that actually revoked the member (5.4, 9.7): a reversed enroll.confirm, or the
|
|
520
|
+
// reversal of its enroll invite.create while the enrollment was still unconfirmed. A completed
|
|
521
|
+
// invite's reversal is record-only (9.7 invite.create row) and never revokes. The revoked status
|
|
522
|
+
// and the revocation boundary below both come from this one list.
|
|
523
|
+
const revocations = [...enrolls.filter(d => d.row.state === "reversed"),
|
|
524
|
+
...(own && own.confirmed_by === null && ownInvite?.row.state === "reversed" ? [ownInvite] : [])];
|
|
525
|
+
const revoked = revocations.length > 0;
|
|
526
|
+
if (!revoked) {
|
|
527
|
+
// Active, suspended or pending: the members row AND the last ledger row equal the projection.
|
|
528
|
+
const expected = projectionAt(m.member_id);
|
|
529
|
+
if (m.role !== expected) flag("member", m.member_id, "member-projection-mismatch", { field: "role" });
|
|
530
|
+
if ((rows.at(-1)?.role ?? null) !== expected) flag("role", m.member_id, "role-ledger-mismatch");
|
|
531
|
+
} else {
|
|
532
|
+
// S5: a revoked member's role is frozen at revocation. The boundary is the decision-log
|
|
533
|
+
// event of the owner veto that revoked it: the veto of a reversal in `revocations`, never a
|
|
534
|
+
// record-only one. Each role row is placed by the event that produced it (above); a row placed
|
|
535
|
+
// after the boundary is refused. The members row AND the last row at or before the boundary must
|
|
536
|
+
// equal the projection at the revoking reversal, never simply the latest row.
|
|
537
|
+
const revokers = revocations.map(d => vetoOf(d.row.id)).filter(Boolean);
|
|
538
|
+
const boundary = Math.min(...revokers.map(v => effectiveEvent.get(v.row.id)?._seq ?? Infinity));
|
|
539
|
+
const cut = Math.min(...revocations.map(d => reversedAt.get(d.row.id) ?? Infinity));
|
|
540
|
+
const placed = (r) => producer(r)?._seq ?? Infinity;
|
|
541
|
+
for (const r of rows) if (!(placed(r) <= boundary)) flag("role", `${m.member_id}/${r.seq}`, "role-after-revocation");
|
|
542
|
+
const expected = projectionAt(m.member_id, cut);
|
|
543
|
+
if (m.role !== expected) flag("member", m.member_id, "member-projection-mismatch", { field: "role" });
|
|
544
|
+
if ((rows.filter(r => placed(r) <= boundary).at(-1)?.role ?? null) !== expected) flag("role", m.member_id, "role-ledger-mismatch");
|
|
545
|
+
}
|
|
546
|
+
const status = revoked ? "revoked"
|
|
547
|
+
: !enrolls.some(d => DONE.includes(d.row.state)) ? "pending"
|
|
548
|
+
: (exp.suspensions ?? []).some(s => s.member_id === m.member_id && s.ended_at === null) ? "suspended" : "active";
|
|
549
|
+
if (status !== m.status) flag("member", m.member_id, "member-projection-mismatch", { field: "status" });
|
|
550
|
+
const activeKeys = (exp.keys ?? []).filter(k => k.principal_kind === "member" && k.principal_id === m.member_id && k.status === "active").length;
|
|
551
|
+
if (m.status === "active" && activeKeys === 0) flag("member", m.member_id, "active-without-key"); // RC-1: no active member without an active key
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- invites, re-bind attempts and suspensions (the D items) ----------------------------
|
|
555
|
+
for (const inv of exp.invites ?? []) {
|
|
556
|
+
const d = justified(inv.decision_id, ["invite.create"]);
|
|
557
|
+
const a = d?.body.args ?? {};
|
|
558
|
+
if (!d || inv.kind !== a.kind || inv.member_id !== (a.kind === "enroll" ? null : a.member_id) || inv.created_by !== d.body.by || inv.created_at !== d.row.created_at)
|
|
559
|
+
flag("invite", inv.invite_id, "invite-decision-mismatch");
|
|
560
|
+
const det = (exp.invite_details ?? []).find(x => x.invite_id === inv.invite_id);
|
|
561
|
+
if (!det || det.decision_id !== inv.decision_id || det.lane !== d?.body.lane || det.intended_display_name !== a.intended_display_name
|
|
562
|
+
|| det.intended_platform !== a.intended_platform) flag("invite", inv.invite_id, "invite-decision-mismatch");
|
|
563
|
+
}
|
|
564
|
+
const endsOf = new Map((exp.rebind_ends ?? []).map(r => [r.invite_id, r]));
|
|
565
|
+
const completedAttempt = (inviteId) => {
|
|
566
|
+
const e = (exp.enrollments ?? []).find(x => x.invite_id === inviteId);
|
|
567
|
+
return !!e && [...decisions.values()].some(d => d.row.class === "D1R" && d.row.subject === e.id && DONE.includes(d.row.state));
|
|
568
|
+
};
|
|
569
|
+
for (const r of exp.rebind_ends ?? []) {
|
|
570
|
+
const inv = inviteOf(r.invite_id), e = (exp.enrollments ?? []).find(x => x.invite_id === r.invite_id);
|
|
571
|
+
const key = e && keysByPk.get(e.public_key);
|
|
572
|
+
const killed = (!e && inv?.voided_at !== null) || key?.status === "revoked";
|
|
573
|
+
const d = r.decision_id && decisions.get(r.decision_id);
|
|
574
|
+
const ok = !!inv && inv.kind === "rebind" && !completedAttempt(r.invite_id) && (
|
|
575
|
+
(r.cause === "invite-expired" && !e && r.at >= inv.expires_at && r.decision_id === null)
|
|
576
|
+
|| (r.cause === "invite-voided" && !e && inv.voided_at !== null && r.decision_id === null)
|
|
577
|
+
|| (r.cause === "enrollment-expired" && !!e && key?.status === "revoked" && r.at >= e.spent_at + ENROLLMENT_TTL && r.decision_id === null)
|
|
578
|
+
|| (r.cause === "cancelled" && killed && !!d && d.row.action === "rebind.cancel" && d.row.subject === r.invite_id && DONE.includes(d.row.state))
|
|
579
|
+
|| (r.cause === "reversed" && killed && !!d && d.row.class === "OWNER" && d.row.action === "veto" && d.row.subject === inv.decision_id)
|
|
580
|
+
|| (r.cause === "reinstated" && killed && !!d && d.row.action === "reinstate" && d.row.subject === inv.member_id && DONE.includes(d.row.state)));
|
|
581
|
+
if (!ok) flag("rebind-end", r.invite_id, "rebind-end-mismatch", { cause: r.cause });
|
|
582
|
+
const ev = firstEvent(security, x => x.kind === "rebind.dead" && x.invite_id === r.invite_id);
|
|
583
|
+
if (!ev || ev.cause !== r.cause || ev._at !== r.at) flag("rebind-end", r.invite_id, "log-mismatch");
|
|
584
|
+
}
|
|
585
|
+
for (const ev of security.filter(x => x.kind === "rebind.dead")) if (!endsOf.has(ev.invite_id)) flag("rebind-end", ev.invite_id, "row-missing");
|
|
586
|
+
for (const s of exp.suspensions ?? []) {
|
|
587
|
+
if (s.reason === "decision") {
|
|
588
|
+
const d = justified(s.decision_id, ["suspend"]), t = reversedBy(s.decision_id);
|
|
589
|
+
// Opened by a suspend; or by the veto of a reinstate, rekey.confirm or rebind.confirm (C3, D5), or
|
|
590
|
+
// by RC-1's guard when a veto would leave the member keyless.
|
|
591
|
+
const ok = (d && d.row.subject === s.member_id) || (t && memberOfDecision(t) === s.member_id
|
|
592
|
+
&& ["reinstate", "rekey.confirm", "rebind.confirm", "suspend", "invite.create"].includes(t.row.action));
|
|
593
|
+
if (!ok) flag("suspension", s.id, "suspension-decision-mismatch");
|
|
594
|
+
} else {
|
|
595
|
+
const inv = inviteOf(s.invite_id);
|
|
596
|
+
if (!inv || inv.kind !== "rebind" || inv.member_id !== s.member_id || !justified(inv.decision_id, ["invite.create"])) flag("suspension", s.id, "suspension-invite-mismatch");
|
|
597
|
+
}
|
|
598
|
+
if (s.ended_by_decision !== null) {
|
|
599
|
+
let ok = false;
|
|
600
|
+
if (s.reason === "decision") {
|
|
601
|
+
const re = justified(s.ended_by_decision, ["reinstate"]), v = reversedBy(s.ended_by_decision);
|
|
602
|
+
ok = (re && re.row.subject === s.member_id) || (v && v.row.id === s.decision_id);
|
|
603
|
+
} else {
|
|
604
|
+
const inv = inviteOf(s.invite_id), dead = endsOf.has(s.invite_id);
|
|
605
|
+
const own = justified(s.ended_by_decision, ["rebind.confirm"]), e = own && enrollmentOf(own.row.subject);
|
|
606
|
+
const re = justified(s.ended_by_decision, ["reinstate"]);
|
|
607
|
+
const sup = justified(s.ended_by_decision, ["invite.create"]), newer = sup && (exp.invites ?? []).find(i => i.decision_id === sup.row.id);
|
|
608
|
+
const veto = decisions.get(s.ended_by_decision);
|
|
609
|
+
ok = (!!e && e.kind === "rebind" && e.invite_id === s.invite_id && e.member_id === s.member_id)
|
|
610
|
+
|| (dead && !!re && re.row.subject === s.member_id)
|
|
611
|
+
|| (dead && !!newer && newer.kind === "rebind" && newer.member_id === s.member_id && newer.invite_id !== s.invite_id)
|
|
612
|
+
|| (dead && !!veto && veto.row.class === "OWNER" && veto.row.action === "veto" && veto.row.state === "effective" && veto.row.subject === inv?.decision_id);
|
|
613
|
+
}
|
|
614
|
+
if (!ok) flag("suspension", s.id, "suspension-end-mismatch");
|
|
615
|
+
}
|
|
616
|
+
if (!firstEvent(security, x => x.kind === "suspension" && x.member_id === s.member_id && x._at === s.started_at)) flag("suspension", s.id, "log-mismatch");
|
|
617
|
+
}
|
|
618
|
+
for (const e of exp.enrollments ?? []) {
|
|
619
|
+
if (e.confirmed_by === null) continue;
|
|
620
|
+
const c = [...decisions.values()].find(d => DONE.includes(d.row.state) && (e.kind === "enroll"
|
|
621
|
+
? d.row.action === "enroll.confirm" && d.row.subject === e.member_id : d.row.action === "rekey.confirm" && d.row.subject === e.id));
|
|
622
|
+
if (!c || c.body.by !== e.confirmed_by || c.body.args?.owner_line_ref !== e.owner_line_ref) flag("enrollment", e.id, "enrollment-confirmation-mismatch");
|
|
623
|
+
}
|
|
624
|
+
for (const c of exp.contributions ?? []) {
|
|
625
|
+
const d = justified(c.decision_id, ["accept"]);
|
|
626
|
+
if (!d || d.row.subject !== c.submission_id) flag("contribution", c.submission_id, "contribution-decision-mismatch");
|
|
627
|
+
}
|
|
628
|
+
// Spec 9.3 (C2): given the pinned relay key, the relay-signed head is required and binds every log's tail.
|
|
629
|
+
if (relayPublicKey !== undefined) problems.push(...verifyExportHead(exp, relayPublicKey, { aud }));
|
|
630
|
+
return { ok: problems.length === 0, problems };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// --- the relay-signed export head (spec 9.3) ----------------------------------------------------
|
|
634
|
+
// The head commits to EVERY log's {log, count, head_hash}, not only the decision log, so a truncated
|
|
635
|
+
// tail of any log (the security log included) and a full rewrite of any chain are detected offline
|
|
636
|
+
// against the pinned relay key. It is signed by the relay key, which only the relay holds.
|
|
637
|
+
|
|
638
|
+
export const EXPORT_HEAD_V = "commons-relay-export-head/1";
|
|
639
|
+
|
|
640
|
+
/** The head of each log in an export's rows: {log, count, head_hash} for every relay log, sorted by name. */
|
|
641
|
+
export function logHeads(rows) {
|
|
642
|
+
return RELAY_LOGS.map(log => {
|
|
643
|
+
const list = (rows ?? []).filter(r => r.log === log).sort((a, b) => a.seq - b.seq);
|
|
644
|
+
return { log, count: list.length, head_hash: list.length ? list.at(-1).hash : GENESIS };
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The relay-signed export: exportRelay plus `head`, read in ONE read transaction so the head and
|
|
650
|
+
* the rows are the same snapshot. `relaySeedHex` is the relay key seed (64 hex), held by the relay only.
|
|
651
|
+
*/
|
|
652
|
+
export function exportRelaySigned(db, { relaySeedHex, aud, at }) {
|
|
653
|
+
if (typeof relaySeedHex !== "string" || !/^[0-9a-f]{64}$/.test(relaySeedHex)) throw new RelayError("relay-key", 500);
|
|
654
|
+
if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
|
|
655
|
+
if (!Number.isSafeInteger(at)) throw new RelayError("invalid-time", 500);
|
|
656
|
+
db.exec("BEGIN");
|
|
657
|
+
try {
|
|
658
|
+
const exp = exportRelay(db);
|
|
659
|
+
const body = { v: EXPORT_HEAD_V, aud, epoch: exp.epoch, at, logs: logHeads(exp.logs) };
|
|
660
|
+
const sig = Buffer.from(ed25519.sign(Buffer.from(jcs(body), "utf8"), Buffer.from(relaySeedHex, "hex"))).toString("hex");
|
|
661
|
+
db.exec("COMMIT");
|
|
662
|
+
return { ...exp, head: { body, sig } };
|
|
663
|
+
} catch (error) {
|
|
664
|
+
try { db.exec("ROLLBACK"); } catch { /* the original error is the one that matters */ }
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Offline check of the head against the pinned relay key (strict Ed25519) and the export's own log rows. */
|
|
670
|
+
export function verifyExportHead(exp, relayPublicKey, { aud } = {}) {
|
|
671
|
+
const out = [];
|
|
672
|
+
const flag = (id, reason, extra = {}) => out.push({ kind: "export-head", id, reason, ...extra });
|
|
673
|
+
const head = exp?.head, body = head?.body;
|
|
674
|
+
if (!body || typeof body !== "object") { flag("head", "head-missing"); return out; }
|
|
675
|
+
let bytes;
|
|
676
|
+
try { bytes = Buffer.from(jcs(body), "utf8"); } catch { flag("head", "head-bad-signature"); return out; }
|
|
677
|
+
if (!verifyBytesStrict(relayPublicKey, bytes, head.sig)) { flag("head", "head-bad-signature"); return out; }
|
|
678
|
+
if (body.v !== EXPORT_HEAD_V) flag("head", "head-version");
|
|
679
|
+
if (body.epoch !== exp.epoch) flag("head", "head-epoch-mismatch");
|
|
680
|
+
if (aud !== undefined && body.aud !== aud) flag("head", "head-aud-mismatch");
|
|
681
|
+
const listed = Array.isArray(body.logs) ? body.logs : [];
|
|
682
|
+
const names = listed.map(l => l?.log);
|
|
683
|
+
for (const log of RELAY_LOGS) if (names.filter(n => n === log).length !== 1) flag(log, "head-log-missing");
|
|
684
|
+
for (const n of names) if (!RELAY_LOGS.includes(n)) flag(String(n), "head-log-unknown");
|
|
685
|
+
const actual = new Map(logHeads(exp.logs).map(h => [h.log, h]));
|
|
686
|
+
for (const l of listed) {
|
|
687
|
+
const a = actual.get(l?.log);
|
|
688
|
+
if (!a) continue;
|
|
689
|
+
if (a.count !== l.count) flag(l.log, "head-count-mismatch", { head: l.count, rows: a.count });
|
|
690
|
+
else if (a.head_hash !== l.head_hash) flag(l.log, "head-hash-mismatch");
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|