@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,1328 @@
|
|
|
1
|
+
// Cloud Commons relay: permission table, role ceiling, decision and role
|
|
2
|
+
// ledgers (spec v3.2 sections 9 and 10). Decisions are made by
|
|
3
|
+
// fleet principals with signed records; this module checks signatures, counts
|
|
4
|
+
// each seat's latest verdict against the rules, and applies the effect in the
|
|
5
|
+
// same transaction. The structural guards are the section 9.3 triggers.
|
|
6
|
+
|
|
7
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
8
|
+
import { tx, getMeta, setMeta, D2_WINDOW_MS } from "./schema.mjs";
|
|
9
|
+
import { RelayError, ROLES, isRecordId, sanitizeText } from "./records.mjs";
|
|
10
|
+
import { verifyBody, appendLog, sha256Hex, verdictPrev } from "./signing.mjs";
|
|
11
|
+
import { jcs } from "../pillar.mjs";
|
|
12
|
+
import { normalizeAllowedPaths } from "../collaboration.mjs";
|
|
13
|
+
import { openClaim, CLAIM_CAPS } from "./claims.mjs";
|
|
14
|
+
import { deliverInTx } from "./mailbox.mjs";
|
|
15
|
+
|
|
16
|
+
import { DECISION_V, APPROVAL_V, VERIFY_V, SECURITY_V, OWNER_CONFIRM_V, COAUTHOR_V, SEAT_MESSAGE_V, FINDING_OPEN_V, FINDING_CLOSE_V, ATTEST_V, measuredFamily } from "./records.mjs";
|
|
17
|
+
export { DECISION_V, APPROVAL_V, VERIFY_V, SECURITY_V, OWNER_CONFIRM_V, COAUTHOR_V, SEAT_MESSAGE_V, FINDING_OPEN_V, FINDING_CLOSE_V, ATTEST_V };
|
|
18
|
+
export const LAPSE_MS = 14 * 86400000;
|
|
19
|
+
|
|
20
|
+
// Section 10 ceiling: no member, at any role, ever holds these.
|
|
21
|
+
export const CEILING = Object.freeze([
|
|
22
|
+
"decision.create", "approve", "object", "reject", "withdraw", "invite.create", "enroll.confirm",
|
|
23
|
+
"suspend", "veto", "freeze", "unfreeze", "verify.result", "security.approve", "coauthor.declare",
|
|
24
|
+
"finding.open", "finding.close", "owner-record.confirm", "message.send", "served_by.attest", "push", "merge", "publish", "deploy", "spend",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const MEMBER_ACTIONS_BY_ROLE = {
|
|
28
|
+
C0: ["heartbeat", "message", "note", "inbox.read", "inbox.ack", "board.read", "claims.read"],
|
|
29
|
+
C1: ["claim", "release", "progress", "result", "evidence", "review-request"],
|
|
30
|
+
C2: ["propose", "advisory-review"],
|
|
31
|
+
C3: [],
|
|
32
|
+
};
|
|
33
|
+
const FLEET_ACTIONS = {
|
|
34
|
+
"ac-seat": ["decision.create", "approve", "object", "reject", "withdraw", "suspend", "owner-record.confirm", "message.send", "coauthor.declare"],
|
|
35
|
+
verifier: ["verify.result", "suspend", "coauthor.declare"],
|
|
36
|
+
security: ["security.approve", "suspend", "coauthor.declare", "finding.open", "finding.close"],
|
|
37
|
+
scope: ["suspend", "freeze", "owner-record.confirm"],
|
|
38
|
+
owner: ["suspend", "veto", "freeze", "unfreeze"],
|
|
39
|
+
// C5: the owner-assigned measurer only attests; it never votes (9.3 approvals_ac_seat_only).
|
|
40
|
+
measurer: ["served_by.attest"],
|
|
41
|
+
};
|
|
42
|
+
export const PRINCIPAL_KINDS = Object.freeze(["member", ...Object.keys(FLEET_ACTIONS)]);
|
|
43
|
+
export const ALL_ACTIONS = Object.freeze([...new Set([
|
|
44
|
+
...Object.values(MEMBER_ACTIONS_BY_ROLE).flat(), ...Object.values(FLEET_ACTIONS).flat(), ...CEILING])]);
|
|
45
|
+
|
|
46
|
+
/** One table keyed by (principal kind, role, action); deny by default. Roles accumulate C0 -> C3. */
|
|
47
|
+
export const PERMISSION_TABLE = (() => {
|
|
48
|
+
const allowed = new Set();
|
|
49
|
+
let carried = [];
|
|
50
|
+
for (const role of ROLES) {
|
|
51
|
+
carried = [...carried, ...MEMBER_ACTIONS_BY_ROLE[role]];
|
|
52
|
+
for (const action of carried) if (!CEILING.includes(action)) allowed.add(`member|${role}|${action}`);
|
|
53
|
+
}
|
|
54
|
+
for (const [kind, actions] of Object.entries(FLEET_ACTIONS)) for (const action of actions) allowed.add(`${kind}|-|${action}`);
|
|
55
|
+
return Object.freeze(allowed);
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
export function permits(kind, role, action) {
|
|
59
|
+
if (kind === "member" && CEILING.includes(action)) return false;
|
|
60
|
+
return PERMISSION_TABLE.has(`${kind}|${kind === "member" ? role : "-"}|${action}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The member's current role: the members.role projection of the roles ledger (9.3 roles_project). */
|
|
64
|
+
export const roleOf = (db, memberId) => db.prepare("SELECT role FROM members WHERE member_id = ?").get(memberId)?.role ?? null;
|
|
65
|
+
|
|
66
|
+
export const isFrozen = (db) => getMeta(db, "frozen") === "1";
|
|
67
|
+
|
|
68
|
+
// Security path classifier (12.3). The owner-signed policy file supplies the list
|
|
69
|
+
// (roster.mjs loadPolicy); this default applies only to a relay with no policy loaded.
|
|
70
|
+
export const DEFAULT_SECURITY_PATHS = Object.freeze([
|
|
71
|
+
"packages/agent-commons/src/relay/", "packages/agent-commons/src/vendor/", "packages/agent-commons/src/trust.mjs",
|
|
72
|
+
"packages/agent-commons/src/carrier.mjs", "packages/agent-commons/src/transport.mjs", "packages/agent-commons/src/pillar.mjs",
|
|
73
|
+
"packages/agent-commons/src/uuaid-namespace.mjs", "packages/agent-commons/src/channels.mjs", "packages/agent-commons/src/governance",
|
|
74
|
+
"packages/agent-commons/src/spaces/", "packages/agent-commons/src/config.mjs", "packages/agent-commons/bin/",
|
|
75
|
+
".github/", "server/operator.ts", "server/http-security.ts", "server/listen_host.ts", "server/adapters/provider_credentials.ts",
|
|
76
|
+
]);
|
|
77
|
+
export const touchesSecurityPath = (paths, policy = DEFAULT_SECURITY_PATHS) =>
|
|
78
|
+
paths.some(path => policy.some(prefix => path === prefix || path.startsWith(prefix)));
|
|
79
|
+
export const securityPaths = (db) => { const v = getMeta(db, "security_paths"); return v ? JSON.parse(v) : DEFAULT_SECURITY_PATHS; };
|
|
80
|
+
|
|
81
|
+
// The family a seat ASSERTS in its served_by, "measured:<family>[:<model>]". Under spec
|
|
82
|
+
// 9.8 an assertion alone earns no family credit: credit needs a measurer's
|
|
83
|
+
// attestation of that verdict whose family equals this assertion (see quorum).
|
|
84
|
+
export { measuredFamily } from "./records.mjs";
|
|
85
|
+
|
|
86
|
+
export const hasOpenFinding = (db, memberId) =>
|
|
87
|
+
!!db.prepare("SELECT 1 FROM security_findings WHERE member_id = ? AND closed_at IS NULL").get(memberId);
|
|
88
|
+
|
|
89
|
+
/** A relay notice to a member's mailbox. A full mailbox never blocks a decision; it raises an alert instead. */
|
|
90
|
+
function notify(db, memberId, recordId, type, body, now) {
|
|
91
|
+
try { deliverInTx(db, memberId, { record_id: recordId, sender: "relay", type, body }, now); }
|
|
92
|
+
catch (error) { alert(db, now, "notice-undelivered", memberId, { record_id: recordId, reason: error.code ?? "error" }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- signed fleet records -------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** An active fleet principal and its active key, both from relay tables (D-i). */
|
|
98
|
+
function fleetPrincipal(db, by) {
|
|
99
|
+
if (typeof by !== "string") throw new RelayError("unauthorized", 401);
|
|
100
|
+
const row = db.prepare(`SELECT f.principal_id, f.kind, f.runtime_family, k.public_key
|
|
101
|
+
FROM fleet_principals f JOIN keys k ON k.principal_kind = 'fleet' AND k.principal_id = f.principal_id
|
|
102
|
+
WHERE f.principal_id = ? AND f.status = 'active' AND k.status = 'active'`).get(by);
|
|
103
|
+
if (!row) throw new RelayError("unauthorized", 401);
|
|
104
|
+
return row;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check a fleet record's own signature against the signer it names (body.by). The
|
|
109
|
+
* record's authority comes from this signature alone. In addition
|
|
110
|
+
* the authenticated request that delivers a record must be signed by the
|
|
111
|
+
* same principal as body.by, and a fresh request nonce never makes an old record
|
|
112
|
+
* valid again (positions and ids are checked here, inside the write transaction).
|
|
113
|
+
*/
|
|
114
|
+
// v3.3.2 (9.3 export contract): a record's signer-asserted body.at must lie within the 6.1
|
|
115
|
+
// window of the relay's acceptance time t: at most 5 minutes before t, at most 60 s after it.
|
|
116
|
+
export const RECORD_WINDOW = Object.freeze({ back_ms: 300000, ahead_ms: 60000 });
|
|
117
|
+
|
|
118
|
+
export function verifySigned(db, signed, version, now) {
|
|
119
|
+
const body = signed?.body, sig = signed?.sig;
|
|
120
|
+
if (!body || typeof body !== "object" || body.v !== version) throw new RelayError("invalid-record", 400, "version");
|
|
121
|
+
const principal = fleetPrincipal(db, body.by);
|
|
122
|
+
if (!verifyBody(principal.public_key, body, sig)) throw new RelayError("unauthorized", 401);
|
|
123
|
+
if (typeof body.served_by !== "string" || !body.served_by.trim()) throw new RelayError("missing-field", 400, "served_by");
|
|
124
|
+
sanitizeText(body.served_by, { field: "served_by", max: 200 });
|
|
125
|
+
if (!Number.isSafeInteger(body.at)) throw new RelayError("invalid-field", 400, "at");
|
|
126
|
+
if (!Number.isSafeInteger(now)) throw new RelayError("invalid-time", 500);
|
|
127
|
+
if (body.at < now - RECORD_WINDOW.back_ms || body.at > now + RECORD_WINDOW.ahead_ms) throw new RelayError("record-time", 400, "at outside the 6.1 window");
|
|
128
|
+
return { body, sig, principal };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function requirePermission(principal, action) {
|
|
132
|
+
if (!permits(principal.kind, null, action)) throw new RelayError("forbidden", 403, action);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// While frozen, every fleet record is refused except the owner's unfreeze and
|
|
136
|
+
// veto and owner-record.confirm (9.2 Freeze); every member request gets 503.
|
|
137
|
+
export const FROZEN_ALLOWED = Object.freeze(["unfreeze", "veto", "owner-record.confirm"]);
|
|
138
|
+
function refuseIfFrozen(db, action) {
|
|
139
|
+
if (isFrozen(db) && !FROZEN_ALLOWED.includes(action)) throw new RelayError("frozen", 503);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function alert(db, at, kind, subject, detail) {
|
|
143
|
+
db.prepare("INSERT INTO alerts(at, kind, subject, detail) VALUES (?, ?, ?, ?)").run(at, kind, subject ?? null, jcs(detail ?? {}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Owner-visible line (9.5): its exact UTF-8 bytes plus one LF are what line_sha256 covers. */
|
|
147
|
+
export const ownerLineSha = (line) => sha256Hex(Buffer.from(`${line}\n`, "utf8"));
|
|
148
|
+
|
|
149
|
+
function writeOutbox(db, subject, line, now) {
|
|
150
|
+
const id = randomUUID();
|
|
151
|
+
db.prepare("INSERT INTO owner_record_outbox(id, subject, line, line_sha256, created_at) VALUES (?, ?, ?, ?, ?)").run(id, subject, line, ownerLineSha(line), now);
|
|
152
|
+
return id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- action catalogue -----------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const hex40 = /^[0-9a-f]{40}$/;
|
|
158
|
+
function allowlist(paths) {
|
|
159
|
+
try { return normalizeAllowedPaths(paths); } catch (error) { throw new RelayError("invalid-field", 400, `paths: ${String(error?.message ?? error).slice(0, 160)}`); }
|
|
160
|
+
}
|
|
161
|
+
function taskArgs(args) {
|
|
162
|
+
if (!args || typeof args !== "object") throw new RelayError("invalid-field", 400, "args");
|
|
163
|
+
for (const key of Object.keys(args)) if (!["title", "description", "kind", "acceptance", "paths", "base_commit"].includes(key)) throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
164
|
+
if (!["work", "improvement", "review"].includes(args.kind)) throw new RelayError("invalid-field", 400, "args.kind");
|
|
165
|
+
if (typeof args.base_commit !== "string" || !hex40.test(args.base_commit)) throw new RelayError("invalid-field", 400, "args.base_commit");
|
|
166
|
+
if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > 64) throw new RelayError("invalid-field", 400, "args.paths");
|
|
167
|
+
const acc = args.acceptance;
|
|
168
|
+
if (!acc || typeof acc !== "object" || !Array.isArray(acc.required_checks)) throw new RelayError("invalid-field", 400, "args.acceptance");
|
|
169
|
+
return {
|
|
170
|
+
title: sanitizeText(args.title, { field: "title", max: 120 }),
|
|
171
|
+
description: sanitizeText(args.description, { field: "description", max: 4096 }),
|
|
172
|
+
kind: args.kind,
|
|
173
|
+
paths: allowlist(args.paths.map((p, i) => sanitizeText(p, { field: `paths[${i}]`, max: 512 }))),
|
|
174
|
+
base_commit: args.base_commit,
|
|
175
|
+
acceptance: {
|
|
176
|
+
criteria: sanitizeText(acc.criteria, { field: "acceptance.criteria", max: 4096 }),
|
|
177
|
+
required_checks: acc.required_checks.map((c, i) => sanitizeText(c, { field: `required_checks[${i}]`, max: 512 })),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Seat actions. The seat decisions of the enrollment flows are made here (spec
|
|
183
|
+
// 15): invite.create, enroll.confirm and rekey.confirm (D0) and rebind.confirm (D1R). The
|
|
184
|
+
// request side (invite spend, nonce spend, key insert, pending member) and its endpoints
|
|
185
|
+
// are in enroll.mjs and server.mjs; tests seed those rows with a test-only helper. review.changes and
|
|
186
|
+
// review.reject are the seat outcomes of the 11.2 state diagram; spec v3.4.4 6.5 names them
|
|
187
|
+
// as D0 fleet record types.
|
|
188
|
+
// D0 actions any AC seat other than the invite creator may sign (P1); the rest are the lane owner's.
|
|
189
|
+
const P1_ACTIONS = Object.freeze(["enroll.confirm", "rekey.confirm"]);
|
|
190
|
+
const ACTIONS = {
|
|
191
|
+
"invite.create": "D0", "enroll.confirm": "D0", "rekey.confirm": "D0", "rebind.cancel": "D0",
|
|
192
|
+
"task.open": "D0", "task.close": "D0", "task.assign": "D0", "claim.release": "D0", "progress.accept": "D0",
|
|
193
|
+
"review.changes": "D0", "review.reject": "D0", "role.c1": "D0", "clock.resume": "D0",
|
|
194
|
+
accept: "D1",
|
|
195
|
+
"rebind.confirm": "D1R",
|
|
196
|
+
"role.change": "D2", reinstate: "D2",
|
|
197
|
+
suspend: "SUSPEND",
|
|
198
|
+
veto: "OWNER", freeze: "OWNER", unfreeze: "OWNER",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const INVITE_TTL_MS = 86400000; // 5.1 (and the 9.3 invites CHECK)
|
|
202
|
+
export const ENROLLMENT_TTL_MS = 3 * 86400000; // 5.3: unconfirmed enrollments expire after 72 h
|
|
203
|
+
|
|
204
|
+
/** The unconfirmed enrollment of a kind, by member (enroll) or by id (rekey), with its invite. */
|
|
205
|
+
function pendingEnrollment(db, kind, memberId, enrollmentId = null) {
|
|
206
|
+
return db.prepare(`SELECT e.*, i.created_by, i.voided_at FROM enrollments e JOIN invites i USING(invite_id)
|
|
207
|
+
WHERE e.kind = ? AND e.confirmed_by IS NULL AND (e.member_id = ? OR e.id = ?)`).get(kind, memberId ?? "", enrollmentId ?? "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 5.3 / P1 / M2: not the invite creator, invite live, not expired, key still pending, owner line confirmed and cited. */
|
|
211
|
+
function confirmable(db, e, d, args, now) {
|
|
212
|
+
for (const key of Object.keys(args)) if (key !== "owner_line_ref") throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
213
|
+
if (!e) throw new RelayError("invalid-state", 409, "no pending enrollment");
|
|
214
|
+
if (e.voided_at !== null) throw new RelayError("invalid-state", 409, "invite voided");
|
|
215
|
+
if (e.created_by === d.by) throw new RelayError("confirmer-is-invite-creator", 409);
|
|
216
|
+
if (now >= e.spent_at + ENROLLMENT_TTL_MS) throw new RelayError("invalid-state", 409, "enrollment expired");
|
|
217
|
+
if (db.prepare("SELECT status FROM keys WHERE public_key = ?").get(e.public_key)?.status !== "pending") throw new RelayError("invalid-state", 409, "key not pending");
|
|
218
|
+
const line = db.prepare("SELECT confirmed_at FROM owner_record_outbox WHERE id = ? AND subject = ?").get(typeof args.owner_line_ref === "string" ? args.owner_line_ref : "", e.id);
|
|
219
|
+
if (!line || line.confirmed_at === null) throw new RelayError("invalid-state", 409, "owner line not confirmed");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function checkEligibility(db, d, now) {
|
|
223
|
+
const args = d.args ?? {};
|
|
224
|
+
const member = () => {
|
|
225
|
+
const m = db.prepare("SELECT m.*, p.lane FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?").get(d.subject);
|
|
226
|
+
if (!m || m.lane !== d.lane) throw new RelayError("invalid-state", 409, "member not in lane");
|
|
227
|
+
return m;
|
|
228
|
+
};
|
|
229
|
+
switch (d.action) {
|
|
230
|
+
case "task.open":
|
|
231
|
+
if (db.prepare("SELECT 1 FROM tasks WHERE task_id = ?").get(d.subject)) throw new RelayError("invalid-state", 409, "task exists");
|
|
232
|
+
taskArgs(args);
|
|
233
|
+
return;
|
|
234
|
+
case "task.close": {
|
|
235
|
+
const t = db.prepare("SELECT status, lane FROM tasks WHERE task_id = ?").get(d.subject);
|
|
236
|
+
if (!t || t.lane !== d.lane || !["open", "needs-triage"].includes(t.status)) throw new RelayError("invalid-state", 409, "task not closable");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
case "claim.release": {
|
|
240
|
+
const c = db.prepare("SELECT c.state, t.lane FROM claims c JOIN tasks t USING(task_id) WHERE c.claim_id = ?").get(d.subject);
|
|
241
|
+
if (!c || c.lane !== d.lane || c.state !== "live") throw new RelayError("invalid-state", 409, "no live claim");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
case "task.assign": {
|
|
245
|
+
// Subject: the task. The lane owner assigns an open task to an active C1+ member of the lane.
|
|
246
|
+
for (const key of Object.keys(args)) if (!["member_id", "lease_ms"].includes(key)) throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
247
|
+
if (args.lease_ms !== undefined && (!Number.isSafeInteger(args.lease_ms) || args.lease_ms < 60000 || args.lease_ms > 86400000)) throw new RelayError("invalid-field", 400, "args.lease_ms");
|
|
248
|
+
const t = db.prepare("SELECT status, lane FROM tasks WHERE task_id = ?").get(d.subject);
|
|
249
|
+
if (!t || t.lane !== d.lane || t.status !== "open") throw new RelayError("invalid-state", 409, "task not open");
|
|
250
|
+
const m = db.prepare("SELECT m.status, m.role, p.lane FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?").get(args.member_id ?? "");
|
|
251
|
+
if (!m || m.lane !== d.lane || m.status !== "active" || !["C1", "C2", "C3"].includes(m.role)) throw new RelayError("invalid-state", 409, "assignee must be an active C1+ member of the lane");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case "progress.accept": {
|
|
255
|
+
// Subject: the claim. args.record_id names one progress record on that claim.
|
|
256
|
+
for (const key of Object.keys(args)) if (key !== "record_id") throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
257
|
+
const pr = db.prepare(`SELECT p.member_id, t.lane FROM progress p JOIN claims c USING(claim_id) JOIN tasks t ON t.task_id = c.task_id
|
|
258
|
+
WHERE p.claim_id = ? AND p.record_id = ?`).get(d.subject, args.record_id ?? "");
|
|
259
|
+
if (!pr || pr.lane !== d.lane) throw new RelayError("invalid-state", 409, "no such progress record");
|
|
260
|
+
if (db.prepare("SELECT 1 FROM progress_acceptances WHERE member_id = ? AND record_id = ?").get(pr.member_id, args.record_id)) throw new RelayError("invalid-state", 409, "already accepted");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case "review.changes": case "review.reject": {
|
|
264
|
+
// Subject: the submission, which must be in review in the lane. A declared co-author never decides it.
|
|
265
|
+
for (const key of Object.keys(args)) if (!(d.action === "review.reject" ? ["disposition"] : ["lease_ms"]).includes(key)) throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
266
|
+
if (d.action === "review.reject" && !["reopen", "close"].includes(args.disposition)) throw new RelayError("invalid-field", 400, "args.disposition");
|
|
267
|
+
if (d.action === "review.changes" && args.lease_ms !== undefined && (!Number.isSafeInteger(args.lease_ms) || args.lease_ms < 60000 || args.lease_ms > 86400000)) throw new RelayError("invalid-field", 400, "args.lease_ms");
|
|
268
|
+
const s = db.prepare(`SELECT s.state, t.lane FROM submissions s JOIN submission_details x USING(submission_id)
|
|
269
|
+
JOIN tasks t ON t.task_id = x.task_id WHERE s.submission_id = ?`).get(d.subject);
|
|
270
|
+
if (!s || s.lane !== d.lane || s.state !== "in-review") throw new RelayError("invalid-state", 409, "submission not in review");
|
|
271
|
+
if (db.prepare("SELECT 1 FROM coauthors WHERE submission_id = ? AND principal = ?").get(d.subject, d.by)) throw new RelayError("author-cannot-approve", 409);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case "role.c1": {
|
|
275
|
+
const m = member();
|
|
276
|
+
if (m.status === "revoked") throw new RelayError("role-frozen-revoked", 409, "a revoked member's role is frozen (D-2)");
|
|
277
|
+
const p = db.prepare("SELECT first_heartbeat_at FROM member_profiles WHERE member_id = ?").get(d.subject);
|
|
278
|
+
if (m.status !== "active" || m.role !== "C0") throw new RelayError("invalid-state", 409, "member must be active C0");
|
|
279
|
+
if (p.first_heartbeat_at === null) throw new RelayError("not-eligible", 409, "no heartbeat yet");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
case "clock.resume":
|
|
283
|
+
// Accepted only once relay time is again ahead of the highest time recorded (11.3).
|
|
284
|
+
if (getMeta(db, "clock_suspect") !== "1") throw new RelayError("invalid-state", 409, "clock is not suspect");
|
|
285
|
+
if (!(now > Number(getMeta(db, "clock_high_water") ?? 0))) throw new RelayError("not-eligible", 409, "relay time is not past the high-water mark");
|
|
286
|
+
return;
|
|
287
|
+
case "accept": {
|
|
288
|
+
const s = db.prepare(`SELECT s.state, t.lane FROM submissions s JOIN submission_details x USING(submission_id)
|
|
289
|
+
JOIN tasks t ON t.task_id = x.task_id WHERE s.submission_id = ?`).get(d.subject);
|
|
290
|
+
if (!s || s.lane !== d.lane || s.state !== "in-review") throw new RelayError("invalid-state", 409, "submission not in review");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
case "role.change": {
|
|
294
|
+
const m = member();
|
|
295
|
+
if (!ROLES.includes(args.role) || Object.keys(args).some(k => k !== "role")) throw new RelayError("invalid-field", 400, "args.role");
|
|
296
|
+
if (m.status === "revoked") throw new RelayError("role-frozen-revoked", 409, "a revoked member's role is frozen (D-2)");
|
|
297
|
+
if (m.status !== "active" || m.role === null || m.role === args.role) throw new RelayError("invalid-state", 409, "role change");
|
|
298
|
+
if (ROLES.indexOf(args.role) > ROLES.indexOf(m.role)) {
|
|
299
|
+
if (args.role === "C1") throw new RelayError("not-eligible", 409, "C1 is a D0 role.c1");
|
|
300
|
+
const { n, days } = credits(db, d.subject); // C3, D-C3b: only accepts that are currently effective
|
|
301
|
+
if (args.role === "C2" && (n < 3 || days < 2)) throw new RelayError("not-eligible", 409, "C2 needs 3 accepted contributions on 2 days");
|
|
302
|
+
// Section 10: C2 also needs no open security finding against the member.
|
|
303
|
+
if (args.role === "C2" && hasOpenFinding(db, d.subject)) throw new RelayError("finding-open", 409, "an open security finding blocks C2");
|
|
304
|
+
if (args.role === "C3" && n < 10) throw new RelayError("not-eligible", 409, "C3 needs 10 accepted contributions");
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "invite.create": {
|
|
309
|
+
// 5.1: subject is the seat's label for the request; the relay makes the invite id and secret.
|
|
310
|
+
for (const key of Object.keys(args)) if (!["kind", "member_id", "intended_display_name", "intended_platform"].includes(key)) throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
311
|
+
if (!["enroll", "rekey", "rebind"].includes(args.kind)) throw new RelayError("invalid-field", 400, "args.kind");
|
|
312
|
+
for (const f of ["intended_display_name", "intended_platform"])
|
|
313
|
+
if (typeof args[f] !== "string" || !sanitizeText(args[f], { field: `args.${f}`, max: 64 }).length) throw new RelayError("invalid-field", 400, `args.${f}`);
|
|
314
|
+
if (args.kind === "enroll") {
|
|
315
|
+
if (args.member_id !== null) throw new RelayError("invalid-field", 400, "args.member_id"); // B2: null for enroll
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const m = db.prepare("SELECT m.status, p.lane FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?").get(typeof args.member_id === "string" ? args.member_id : "");
|
|
319
|
+
if (!m || m.lane !== d.lane) throw new RelayError("invalid-state", 409, "member not in lane");
|
|
320
|
+
// RC-2 (security review of the D items): only an active or suspended member gets a re-key or re-bind invite;
|
|
321
|
+
// a revoked member's recovery is a fresh enrollment.
|
|
322
|
+
if (!["active", "suspended"].includes(m.status)) throw new RelayError("invalid-state", 409, "member not enrolled");
|
|
323
|
+
if (args.kind === "rekey" && (m.status !== "active" || db.prepare("SELECT 1 FROM suspensions WHERE member_id = ? AND ended_at IS NULL").get(args.member_id)))
|
|
324
|
+
throw new RelayError("invalid-state", 409, "re-key needs an active, unsuspended member"); // RD2
|
|
325
|
+
// D1/D3: at most one live attempt (the 9.3 rebind_one_live_attempt); a retry is allowed once the old one is dead.
|
|
326
|
+
if (args.kind === "rebind" && liveAttempt(db, args.member_id)) throw new RelayError("rebind-attempt-live", 409);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
case "enroll.confirm": {
|
|
330
|
+
// 5.3: subject is the pending member (its C0 row cites this decision); P1 any AC seat but the invite creator.
|
|
331
|
+
const m = member();
|
|
332
|
+
if (m.status !== "pending") throw new RelayError("invalid-state", 409, "member not pending");
|
|
333
|
+
confirmable(db, pendingEnrollment(db, "enroll", d.subject), d, args, now);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
case "rekey.confirm": {
|
|
337
|
+
// 5.4: subject is the re-key enrollment; the member must be active with no open suspension (RD2).
|
|
338
|
+
const e = pendingEnrollment(db, "rekey", null, d.subject);
|
|
339
|
+
const m = e && db.prepare("SELECT m.status, p.lane FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?").get(e.member_id);
|
|
340
|
+
if (!m || m.lane !== d.lane) throw new RelayError("invalid-state", 409, "member not in lane");
|
|
341
|
+
if (m.status !== "active" || db.prepare("SELECT 1 FROM suspensions WHERE member_id = ? AND ended_at IS NULL").get(e.member_id))
|
|
342
|
+
throw new RelayError("invalid-state", 409, "re-key needs an active, unsuspended member");
|
|
343
|
+
confirmable(db, e, d, args, now);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
case "rebind.cancel": {
|
|
347
|
+
// D3: the lane owner ends a live attempt at once (e.g. an invite delivered to the wrong session).
|
|
348
|
+
// Not a recovery: the member stays suspended. Subject: the invite id; args {reason}.
|
|
349
|
+
for (const key of Object.keys(args)) if (key !== "reason") throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
350
|
+
if (typeof args.reason !== "string" || !sanitizeText(args.reason, { field: "args.reason", max: 128 }).length) throw new RelayError("invalid-field", 400, "args.reason");
|
|
351
|
+
const a = attemptOf(db, d.subject);
|
|
352
|
+
if (!a) throw new RelayError("invalid-state", 409, "no re-bind invite");
|
|
353
|
+
if (a.lane !== d.lane) throw new RelayError("invalid-state", 409, "invite not in lane");
|
|
354
|
+
if (a.completed) throw new RelayError("rebind-attempt-completed", 409);
|
|
355
|
+
if (a.dead) throw new RelayError("rebind-attempt-dead", 409);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
case "rebind.confirm": {
|
|
359
|
+
// Subject: the re-bind enrollment. Its own re-bind suspension is still open (RD2).
|
|
360
|
+
for (const key of Object.keys(args)) throw new RelayError("unknown-field", 400, `args.${key}`);
|
|
361
|
+
const e = db.prepare("SELECT e.kind, e.member_id, e.invite_id, e.public_key, i.voided_at FROM enrollments e JOIN invites i USING(invite_id) WHERE e.id = ?").get(d.subject);
|
|
362
|
+
if (!e || e.kind !== "rebind") throw new RelayError("invalid-state", 409, "no re-bind enrollment");
|
|
363
|
+
// D2: a D1R about a dead attempt never takes effect (9.3 rebind_confirm_needs_live_attempt).
|
|
364
|
+
if (db.prepare("SELECT 1 FROM rebind_ends WHERE invite_id = ?").get(e.invite_id)) throw new RelayError("rebind-attempt-dead", 409);
|
|
365
|
+
if (e.voided_at !== null) throw new RelayError("invalid-state", 409, "invite voided");
|
|
366
|
+
if (db.prepare("SELECT status FROM members WHERE member_id = ?").get(e.member_id).status === "revoked") throw new RelayError("invalid-state", 409, "member revoked");
|
|
367
|
+
if (!db.prepare("SELECT 1 FROM suspensions WHERE member_id = ? AND reason = 'rebind' AND invite_id = ? AND ended_at IS NULL").get(e.member_id, e.invite_id))
|
|
368
|
+
throw new RelayError("invalid-state", 409, "re-bind not open");
|
|
369
|
+
if (db.prepare("SELECT status FROM keys WHERE public_key = ?").get(e.public_key)?.status !== "pending") throw new RelayError("invalid-state", 409, "re-bind key not pending");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
case "reinstate":
|
|
373
|
+
// D3: return on the existing key. Needs >= 1 active key, at proposal and at effect (a pending
|
|
374
|
+
// reinstate that loses it lapses with cause no-active-key). Its effect kills any live attempt.
|
|
375
|
+
if (member().status !== "suspended") throw new RelayError("invalid-state", 409, "member not suspended");
|
|
376
|
+
if (activeKeys(db, d.subject) === 0) throw new RelayError("no-active-key", 409);
|
|
377
|
+
return;
|
|
378
|
+
case "suspend": {
|
|
379
|
+
const m = db.prepare("SELECT status FROM members WHERE member_id = ?").get(d.subject);
|
|
380
|
+
if (!m || m.status === "revoked") throw new RelayError("invalid-state", 409, "member");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case "veto": {
|
|
384
|
+
// C3: the owner vetoes a pending D2 or reverses an effective decision; owner records are not reversible.
|
|
385
|
+
const t = db.prepare("SELECT class, state FROM decisions WHERE id = ?").get(d.subject);
|
|
386
|
+
if (!t) throw new RelayError("invalid-state", 409, "decision");
|
|
387
|
+
if (t.class === "OWNER") throw new RelayError("invalid-state", 409, "owner records are not reversible");
|
|
388
|
+
if (!(t.state === "effective" || (t.state === "pending" && t.class === "D2"))) throw new RelayError("invalid-state", 409, "veto needs a pending D2 or an effective decision");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case "freeze": if (isFrozen(db)) throw new RelayError("invalid-state", 409, "already frozen"); return;
|
|
392
|
+
case "unfreeze": if (!isFrozen(db)) throw new RelayError("invalid-state", 409, "not frozen"); return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- create, verdicts, settle -----------------------------------------------
|
|
397
|
+
|
|
398
|
+
const DECISION_KEYS = ["v", "id", "class", "action", "subject", "lane", "sponsor", "args", "by", "served_by", "at"];
|
|
399
|
+
|
|
400
|
+
// invite.create returns its secret once, to the call that created it (5.1); it is never stored.
|
|
401
|
+
let inviteOut = null;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A fleet principal records a decision. For D0, D1 and D1R an AC seat creator's signature
|
|
405
|
+
* is also its first verdict. An invite.create that takes effect returns `invite` once.
|
|
406
|
+
*/
|
|
407
|
+
export function createDecision(db, signed, now) {
|
|
408
|
+
inviteOut = null;
|
|
409
|
+
return tx(db, () => {
|
|
410
|
+
const { body, sig, principal } = verifySigned(db, signed, DECISION_V, now);
|
|
411
|
+
const cls = Object.hasOwn(ACTIONS, body.action) ? ACTIONS[body.action] : null;
|
|
412
|
+
if (!cls || body.class !== cls) throw new RelayError("unknown-action", 400, String(body.action));
|
|
413
|
+
refuseIfFrozen(db, body.action);
|
|
414
|
+
for (const key of Object.keys(body)) if (!DECISION_KEYS.includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
415
|
+
if (!isRecordId(body.id) || !isRecordId(body.subject)) throw new RelayError("invalid-field", 400, "id/subject");
|
|
416
|
+
requirePermission(principal, cls === "SUSPEND" ? "suspend" : cls === "OWNER" ? body.action : "decision.create");
|
|
417
|
+
if (["D0", "D1", "D2"].includes(cls)) {
|
|
418
|
+
const lane = db.prepare("SELECT owner_seat FROM lanes WHERE lane = ?").get(body.lane ?? "");
|
|
419
|
+
if (!lane) throw new RelayError("invalid-field", 400, "lane");
|
|
420
|
+
// D0 is created by the lane owner (enroll.confirm and rekey.confirm by any AC seat other
|
|
421
|
+
// than the invite creator, P1). Any AC seat may propose a D1 (N3; the lane owner must still
|
|
422
|
+
// approve it) or sponsor a D2.
|
|
423
|
+
if (cls === "D0" && !P1_ACTIONS.includes(body.action) && lane.owner_seat !== principal.principal_id) throw new RelayError("forbidden", 403, "not the lane owner");
|
|
424
|
+
} else if (body.lane !== undefined) throw new RelayError("invalid-field", 400, "lane");
|
|
425
|
+
if (cls === "D2" ? body.sponsor !== principal.principal_id : body.sponsor !== undefined) throw new RelayError("invalid-field", 400, "sponsor");
|
|
426
|
+
checkEligibility(db, body, now);
|
|
427
|
+
// Authors from provenance (V5): the submitting member plus declared fleet co-authors.
|
|
428
|
+
const authors = body.action === "accept"
|
|
429
|
+
? [db.prepare("SELECT member_id FROM submissions WHERE submission_id = ?").get(body.subject).member_id,
|
|
430
|
+
...db.prepare("SELECT principal FROM coauthors WHERE submission_id = ?").all(body.subject).map(r => r.principal)]
|
|
431
|
+
: [];
|
|
432
|
+
if (authors.includes(principal.principal_id)) throw new RelayError("author-cannot-approve", 409);
|
|
433
|
+
try {
|
|
434
|
+
// S1: decisions.lane is the signed body's lane, set exactly for D0, D1 and D2.
|
|
435
|
+
db.prepare(`INSERT INTO decisions(id, class, action, subject, sponsor, lane, state, served_by, created_at, body, sig)
|
|
436
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`)
|
|
437
|
+
.run(body.id, cls, body.action, body.subject, body.sponsor ?? null, ["D0", "D1", "D2"].includes(cls) ? body.lane : null, body.served_by, now, jcs(body), sig);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (/UNIQUE|PRIMARY KEY/.test(error.message)) throw new RelayError("duplicate-decision", 409);
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
442
|
+
const insertAuthor = db.prepare("INSERT INTO decision_authors(decision_id, principal) VALUES (?, ?)");
|
|
443
|
+
for (const author of new Set(authors)) insertAuthor.run(body.id, author);
|
|
444
|
+
// The creator's signed decision record is its implicit verdict at seq 1 (the decision is new, so no earlier one exists).
|
|
445
|
+
if (["D0", "D1", "D1R"].includes(cls) && principal.kind === "ac-seat") insertVerdict(db, body.id, principal, "approve", body.served_by, now, jcs(body), sig, 1, null);
|
|
446
|
+
appendLog(db, "decision", { kind: "decision.created", id: body.id, class: cls, action: body.action, subject: body.subject, by: principal.principal_id, served_by: body.served_by }, now);
|
|
447
|
+
writeOutbox(db, body.id, `commons-relay ${cls} ${body.action} subject=${body.subject} decision=${body.id} by=${principal.principal_id} served_by=${JSON.stringify(body.served_by)} at=${new Date(now).toISOString()}`, now);
|
|
448
|
+
const state = settleInTx(db, body.id, now);
|
|
449
|
+
const invite = inviteOut;
|
|
450
|
+
inviteOut = null;
|
|
451
|
+
return invite ? { id: body.id, state, invite } : { id: body.id, state };
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const VERDICT_ERRORS = ["author-cannot-approve", "sponsor-excluded", "decision-closed", "approver-not-in-roster", "approval-seq", "confirmer-is-invite-creator",
|
|
456
|
+
"object-d2-only", "implicit-approval", "approver-not-ac-seat"];
|
|
457
|
+
/**
|
|
458
|
+
* Insert a verdict at the given position, with its chain link and body hash (9.3 v3.3:
|
|
459
|
+
* approvals_seq, approvals_prev_chain and UNIQUE(decision_id, body_sha256) refuse any other).
|
|
460
|
+
*/
|
|
461
|
+
function insertVerdict(db, decisionId, principal, verdict, servedBy, now, bodyText, sig, seq, prev) {
|
|
462
|
+
try {
|
|
463
|
+
db.prepare(`INSERT INTO approvals(decision_id, approver, seq, prev, verdict, approver_kind, approver_family, served_by, signed_at, body, body_sha256, sig)
|
|
464
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
465
|
+
.run(decisionId, principal.principal_id, seq, prev, verdict, principal.kind, principal.runtime_family, servedBy, now, bodyText, verdictPrev(bodyText), sig);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
const known = VERDICT_ERRORS.find(c => error.message.includes(c));
|
|
468
|
+
if (known) throw new RelayError(known, 409);
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
return seq;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Append one verdict (approve, object, reject, withdraw). Each seat's latest verdict is
|
|
476
|
+
* its current one (A1). The verdict's position is part of what the seat signed:
|
|
477
|
+
* `seq` must be the seat's next number on this decision and `prev` the sha256 of the
|
|
478
|
+
* seat's previous signed body there (null at seq 1; a D0, D1 or D1R creator's seq 1 is
|
|
479
|
+
* its decision record). Both are checked in this write transaction, so a captured
|
|
480
|
+
* verdict can never be appended again: after any later verdict by that seat its
|
|
481
|
+
* position is taken, whatever request or nonce carries it.
|
|
482
|
+
*/
|
|
483
|
+
export function addVerdict(db, signed, now) {
|
|
484
|
+
return tx(db, () => {
|
|
485
|
+
const { body, sig, principal } = verifySigned(db, signed, APPROVAL_V, now);
|
|
486
|
+
for (const key of Object.keys(body)) if (!["v", "decision_id", "verdict", "seq", "prev", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
487
|
+
if (!["approve", "object", "reject", "withdraw"].includes(body.verdict)) throw new RelayError("invalid-field", 400, "verdict");
|
|
488
|
+
if (!Number.isSafeInteger(body.seq) || body.seq < 1) throw new RelayError("invalid-field", 400, "seq");
|
|
489
|
+
if (body.prev !== null && !(typeof body.prev === "string" && /^[0-9a-f]{64}$/.test(body.prev))) throw new RelayError("invalid-field", 400, "prev");
|
|
490
|
+
refuseIfFrozen(db, "approve");
|
|
491
|
+
requirePermission(principal, body.verdict);
|
|
492
|
+
const decision = db.prepare("SELECT id FROM decisions WHERE id = ?").get(body.decision_id ?? "");
|
|
493
|
+
if (!decision) throw new RelayError("unknown-decision", 404);
|
|
494
|
+
const last = db.prepare("SELECT seq, body FROM approvals WHERE decision_id = ? AND approver = ? ORDER BY seq DESC LIMIT 1").get(decision.id, principal.principal_id);
|
|
495
|
+
if (body.seq !== (last?.seq ?? 0) + 1) throw new RelayError("approval-seq", 409, `expected seq ${(last?.seq ?? 0) + 1}`);
|
|
496
|
+
// Spec 9.2: a wrong prev is refused with the same 409 approval-seq.
|
|
497
|
+
if (body.prev !== (last ? verdictPrev(last.body) : null)) throw new RelayError("approval-seq", 409, "prev does not name the previous verdict");
|
|
498
|
+
const seq = insertVerdict(db, decision.id, principal, body.verdict, body.served_by, now, jcs(body), sig, body.seq, body.prev);
|
|
499
|
+
appendLog(db, "decision", { kind: "verdict", decision_id: decision.id, by: principal.principal_id, seq, verdict: body.verdict, served_by: body.served_by }, now);
|
|
500
|
+
return { decision_id: decision.id, seq, state: settleInTx(db, decision.id, now) };
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
export const addApproval = addVerdict;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The family a counted verdict is credited with (C5, spec 9.8): the family of an
|
|
507
|
+
* attestation of exactly that verdict (decision, seat, seq), signed by an active
|
|
508
|
+
* measurer, equal to the family the seat asserted. Otherwise null: the verdict still
|
|
509
|
+
* counts toward the number of approvals, with no family.
|
|
510
|
+
*/
|
|
511
|
+
function creditedFamily(db, decisionId, approver, seq, servedBy) {
|
|
512
|
+
const asserted = measuredFamily(servedBy);
|
|
513
|
+
if (!asserted) return null;
|
|
514
|
+
const hit = db.prepare(`SELECT 1 FROM served_by_attestations a JOIN fleet_principals f ON f.principal_id = a.measurer
|
|
515
|
+
WHERE a.decision_id = ? AND a.target_by = ? AND a.target_seq = ? AND a.family = ?
|
|
516
|
+
AND f.kind = 'measurer' AND f.status = 'active'`).get(decisionId, approver, seq, asserted);
|
|
517
|
+
return hit ? asserted : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Quorum shape from committed rows: each AC seat's latest verdict counts (9.3 reducer contract). */
|
|
521
|
+
export function quorum(db, decisionId) {
|
|
522
|
+
const d = db.prepare("SELECT * FROM decisions WHERE id = ?").get(decisionId);
|
|
523
|
+
const latest = db.prepare(`SELECT a.approver, a.seq, a.verdict, a.served_by
|
|
524
|
+
FROM approvals a JOIN fleet_principals f ON f.principal_id = a.approver
|
|
525
|
+
WHERE a.decision_id = ? AND f.kind = 'ac-seat' AND f.status = 'active'
|
|
526
|
+
AND a.seq = (SELECT max(seq) FROM approvals b WHERE b.decision_id = a.decision_id AND b.approver = a.approver)`).all(decisionId);
|
|
527
|
+
const authors = new Set(db.prepare("SELECT principal FROM decision_authors WHERE decision_id = ?").all(decisionId).map(r => r.principal));
|
|
528
|
+
const approve = latest.filter(r => r.verdict === "approve" && !authors.has(r.approver) && r.approver !== d.sponsor);
|
|
529
|
+
const credited = approve.map(r => creditedFamily(db, decisionId, r.approver, r.seq, r.served_by));
|
|
530
|
+
const lane = JSON.parse(d.body).lane;
|
|
531
|
+
const laneOwner = lane ? db.prepare("SELECT owner_seat FROM lanes WHERE lane = ?").get(lane)?.owner_seat : null;
|
|
532
|
+
return {
|
|
533
|
+
approvals: approve.map(r => r.approver),
|
|
534
|
+
// C5: diversity rules count distinct CREDITED families; the roster label and a bare
|
|
535
|
+
// measured: assertion earn nothing.
|
|
536
|
+
families: [...new Set(credited.filter(Boolean))],
|
|
537
|
+
uncredited: approve.filter((r, i) => credited[i] === null).map(r => r.approver),
|
|
538
|
+
laneOwnerApproved: approve.some(r => r.approver === laneOwner),
|
|
539
|
+
objections: latest.filter(r => r.verdict === "object" && r.approver !== d.sponsor).map(r => r.approver),
|
|
540
|
+
rejections: latest.filter(r => r.verdict === "reject").map(r => r.approver),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function settleInTx(db, id, now) {
|
|
545
|
+
const d = db.prepare("SELECT * FROM decisions WHERE id = ?").get(id);
|
|
546
|
+
if (d.state !== "pending") return d.state;
|
|
547
|
+
// A freeze holds every pending effect (9.2 Freeze), except the owner's own unfreeze and veto.
|
|
548
|
+
if (isFrozen(db) && !["unfreeze", "veto"].includes(d.action)) return d.state;
|
|
549
|
+
if (now - d.created_at >= LAPSE_MS) return transition(db, d, "lapsed", now);
|
|
550
|
+
// Board and role effects re-check their preconditions when they take effect: a decision
|
|
551
|
+
// blocked until later must not act on a state that has moved on. An open security finding
|
|
552
|
+
// keeps a C2 promotion pending; any other failed precondition lapses the decision.
|
|
553
|
+
if (RECHECK.has(d.action)) {
|
|
554
|
+
try { checkEligibility(db, JSON.parse(d.body), now); }
|
|
555
|
+
catch (error) { return error.code === "finding-open" ? "pending" : transition(db, d, "lapsed", now, { cause: error.code ?? "precondition" }); }
|
|
556
|
+
}
|
|
557
|
+
const q = quorum(db, id);
|
|
558
|
+
switch (d.class) {
|
|
559
|
+
case "SUSPEND": case "OWNER": return effect(db, d, now);
|
|
560
|
+
// D0: the lane owner, except the P1 confirmations, which any one AC seat but the invite creator signs.
|
|
561
|
+
case "D0": return q.rejections.length || !(P1_ACTIONS.includes(d.action) ? q.approvals.length > 0 : q.laneOwnerApproved) ? "pending" : effect(db, d, now);
|
|
562
|
+
case "D1": {
|
|
563
|
+
if (q.rejections.length || !q.laneOwnerApproved || q.approvals.length < 2 || q.families.length < 2) return "pending";
|
|
564
|
+
const s = db.prepare("SELECT s.state, x.kind, x.body, x.body_sha256 FROM submissions s JOIN submission_details x USING(submission_id) WHERE s.submission_id = ?").get(d.subject);
|
|
565
|
+
if (s.state !== "in-review") return "pending";
|
|
566
|
+
if (s.kind === "patch") {
|
|
567
|
+
const verified = db.prepare(`SELECT 1 FROM verify_results v JOIN fleet_principals f ON f.principal_id = v.verifier
|
|
568
|
+
WHERE v.submission_id = ? AND v.passed = 1 AND v.patch_sha256 = ? AND f.kind = 'verifier'`).get(d.subject, s.body_sha256);
|
|
569
|
+
if (!verified) return "pending";
|
|
570
|
+
if (touchesSecurityPath(JSON.parse(s.body).map(f => f.path), securityPaths(db))) {
|
|
571
|
+
const sec = db.prepare(`SELECT 1 FROM security_approvals a JOIN fleet_principals f ON f.principal_id = a.reviewer
|
|
572
|
+
WHERE a.submission_id = ? AND f.kind = 'security'`).get(d.subject);
|
|
573
|
+
if (!sec) return "pending";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return effect(db, d, now);
|
|
577
|
+
}
|
|
578
|
+
case "D1R":
|
|
579
|
+
// 9.2: two AC seats on two measured families, neither the invite creator (the 9.3
|
|
580
|
+
// trigger refuses that approval); a reject blocks (A1); effect only after the
|
|
581
|
+
// owner-visible posting is confirmed (5.4).
|
|
582
|
+
if (q.rejections.length || q.approvals.length < 2 || q.families.length < 2) return "pending";
|
|
583
|
+
if (d.owner_posted_at === null) return "pending";
|
|
584
|
+
return effect(db, d, now);
|
|
585
|
+
case "D2":
|
|
586
|
+
// A live non-sponsor objection, or any rejection, blocks until withdrawn (A1).
|
|
587
|
+
if (q.objections.length || q.rejections.length) return "pending";
|
|
588
|
+
if (q.approvals.length < 3 || q.families.length < 2) return "pending";
|
|
589
|
+
if (d.owner_posted_at === null || d.effect_at === null || now < d.effect_at) return "pending";
|
|
590
|
+
return effect(db, d, now);
|
|
591
|
+
default: return "pending";
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const RECHECK = new Set(["task.close", "task.assign", "claim.release", "progress.accept", "review.changes", "review.reject", "role.c1", "role.change",
|
|
596
|
+
"reinstate", "rebind.confirm", "invite.create", "enroll.confirm", "rekey.confirm", "rebind.cancel"]);
|
|
597
|
+
|
|
598
|
+
function transition(db, d, state, now, extra = {}) {
|
|
599
|
+
db.prepare("UPDATE decisions SET state = ? WHERE id = ?").run(state, d.id);
|
|
600
|
+
appendLog(db, "decision", { kind: `decision.${state}`, id: d.id, ...extra }, now);
|
|
601
|
+
return state;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Open a suspension (P3, 9.4) inside the caller's transaction. The 9.3 trigger suspends
|
|
606
|
+
* the member, releases its live claims and holds its in-review submissions; this reopens
|
|
607
|
+
* the released tasks (attempts unchanged), holds the held ones and drops pending seat
|
|
608
|
+
* messages. Used by the suspend effect and, for reason "rebind", by the rebind invite.
|
|
609
|
+
*/
|
|
610
|
+
export function openSuspensionInTx(db, { memberId, reason, decisionId = null, inviteId = null, by, now }) {
|
|
611
|
+
const released = db.prepare("SELECT task_id FROM claims WHERE member_id = ? AND state = 'live'").all(memberId).map(r => r.task_id);
|
|
612
|
+
const held = db.prepare("SELECT x.task_id FROM submissions s JOIN submission_details x USING(submission_id) WHERE s.member_id = ? AND s.state = 'in-review'").all(memberId).map(r => r.task_id);
|
|
613
|
+
db.prepare("INSERT INTO suspensions(id, member_id, reason, decision_id, invite_id, started_at, ended_at, ended_by_decision) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)")
|
|
614
|
+
.run(randomUUID(), memberId, reason, decisionId, inviteId, now);
|
|
615
|
+
for (const t of released) db.prepare("UPDATE tasks SET status = 'open', updated_at = ? WHERE task_id = ?").run(now, t);
|
|
616
|
+
for (const t of held) db.prepare("UPDATE tasks SET status = 'held', updated_at = ? WHERE task_id = ?").run(now, t);
|
|
617
|
+
db.prepare("UPDATE seat_feed SET state = 'dropped' WHERE member_id = ? AND state = 'pending'").run(memberId);
|
|
618
|
+
const ref = reason === "decision" ? { decision: decisionId } : { invite: inviteId };
|
|
619
|
+
alert(db, now, "suspension", memberId, { ...ref, by });
|
|
620
|
+
appendLog(db, "security", { kind: "suspension", member_id: memberId, ...ref, by }, now);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- re-bind attempts (the D items, spec 5.4) -------------------------------------------
|
|
624
|
+
// An attempt is a re-bind invite I, the suspension S its invite.create opened, the enrollment E
|
|
625
|
+
// if I was spent, and any D1R about E. Completed: a D1R about E is (or was) effective. Dead: a
|
|
626
|
+
// rebind_ends row exists for I. Live: neither. At most one live attempt per member (9.3).
|
|
627
|
+
|
|
628
|
+
const activeKeys = (db, memberId) =>
|
|
629
|
+
db.prepare("SELECT count(*) n FROM keys WHERE principal_kind = 'member' AND principal_id = ? AND status = 'active'").get(memberId).n;
|
|
630
|
+
|
|
631
|
+
/** The attempt of a re-bind invite, or null when the invite is not a re-bind. */
|
|
632
|
+
export function attemptOf(db, inviteId) {
|
|
633
|
+
const i = db.prepare(`SELECT i.*, x.lane FROM invites i LEFT JOIN invite_details x USING(invite_id) WHERE i.invite_id = ? AND i.kind = 'rebind'`).get(typeof inviteId === "string" ? inviteId : "");
|
|
634
|
+
if (!i) return null;
|
|
635
|
+
const e = db.prepare("SELECT * FROM enrollments WHERE invite_id = ?").get(i.invite_id) ?? null;
|
|
636
|
+
const completed = !!e && !!db.prepare("SELECT 1 FROM decisions WHERE class = 'D1R' AND subject = ? AND state IN ('effective','reversed')").get(e.id);
|
|
637
|
+
const dead = !!db.prepare("SELECT 1 FROM rebind_ends WHERE invite_id = ?").get(i.invite_id);
|
|
638
|
+
return { ...i, enrollment: e, completed, dead, live: !completed && !dead };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The member's live attempt: an open re-bind suspension whose invite has no rebind_ends row. */
|
|
642
|
+
export const liveAttempt = (db, memberId) => db.prepare(`SELECT s.invite_id FROM suspensions s WHERE s.member_id = ? AND s.reason = 'rebind'
|
|
643
|
+
AND s.ended_at IS NULL AND NOT EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = s.invite_id)`).get(memberId) ?? null;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Record an attempt's death once (D2): pending D1Rs about its enrollment lapse with `lapseCause`,
|
|
647
|
+
* a rebind_ends row (9.3 rebind_ends_only_dead checks it), and, once each, the security-log event
|
|
648
|
+
* rebind.dead, an alert to seats and the owner line rebind.dead:<invite_id>. The caller has already
|
|
649
|
+
* made the attempt dead in fact (invite voided or expired, or the spent key revoked).
|
|
650
|
+
*/
|
|
651
|
+
function recordDeathInTx(db, inviteId, cause, decisionId, now, lapseCause) {
|
|
652
|
+
const i = db.prepare("SELECT invite_id, member_id FROM invites WHERE invite_id = ?").get(inviteId);
|
|
653
|
+
const e = db.prepare("SELECT id FROM enrollments WHERE invite_id = ?").get(inviteId);
|
|
654
|
+
if (e) for (const d of db.prepare("SELECT * FROM decisions WHERE class = 'D1R' AND subject = ? AND state = 'pending'").all(e.id))
|
|
655
|
+
transition(db, d, "lapsed", now, { cause: lapseCause });
|
|
656
|
+
db.prepare("INSERT INTO rebind_ends(invite_id, cause, decision_id, at) VALUES (?, ?, ?, ?)").run(inviteId, cause, decisionId, now);
|
|
657
|
+
appendLog(db, "security", { kind: "rebind.dead", member_id: i.member_id, invite_id: inviteId, cause, decision: decisionId }, now);
|
|
658
|
+
alert(db, now, "rebind.dead", i.member_id, { invite_id: inviteId, cause, decision: decisionId });
|
|
659
|
+
writeOutbox(db, `rebind.dead:${inviteId}`, `commons-relay rebind.dead invite=${inviteId} member=${i.member_id} cause=${cause}${decisionId ? ` decision=${decisionId}` : ""} at=${new Date(now).toISOString()}`, now);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Kill a live attempt (cancel, reversal, reinstate): void it if unspent, else revoke its pending key; then record the death. */
|
|
663
|
+
function killAttemptInTx(db, inviteId, cause, decisionId, now, lapseCause) {
|
|
664
|
+
const e = db.prepare("SELECT public_key FROM enrollments WHERE invite_id = ?").get(inviteId);
|
|
665
|
+
if (e) db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE public_key = ? AND status = 'pending'").run(now, e.public_key);
|
|
666
|
+
else db.prepare("UPDATE invites SET voided_at = ? WHERE invite_id = ? AND voided_at IS NULL").run(now, inviteId);
|
|
667
|
+
recordDeathInTx(db, inviteId, cause, decisionId, now, lapseCause);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** End every open re-bind suspension of the member's dead attempts, citing `decisionId` (D3 retry, reinstate). */
|
|
671
|
+
function endDeadRebindSuspensionsInTx(db, memberId, decisionId, now) {
|
|
672
|
+
db.prepare(`UPDATE suspensions SET ended_at = ?, ended_by_decision = ? WHERE member_id = ? AND reason = 'rebind' AND ended_at IS NULL
|
|
673
|
+
AND EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = suspensions.invite_id)`).run(now, decisionId, memberId);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* RC-1 (security review of the D items): before a reversal ends suspensions (those matching `where`), if the
|
|
678
|
+
* member is not revoked, has no active key, and no other suspension would stay open, open a decision
|
|
679
|
+
* suspension citing the veto first. A keyless member then leaves only by a completed owner re-bind
|
|
680
|
+
* invite (which ends only its own suspension) followed by a D2 reinstate (which needs an active key).
|
|
681
|
+
*/
|
|
682
|
+
function keylessGuardInTx(db, memberId, veto, owner, now, where, params) {
|
|
683
|
+
const m = db.prepare("SELECT status FROM members WHERE member_id = ?").get(memberId);
|
|
684
|
+
if (!m || m.status === "revoked" || activeKeys(db, memberId) > 0) return false;
|
|
685
|
+
const others = db.prepare(`SELECT count(*) n FROM suspensions WHERE member_id = ? AND ended_at IS NULL AND NOT (${where})`).get(memberId, ...params).n;
|
|
686
|
+
if (others > 0) return false;
|
|
687
|
+
openSuspensionInTx(db, { memberId, reason: "decision", decisionId: veto.id, by: owner, now });
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The sweep's deaths (D2), called inside its transaction (never while frozen or clock-suspect; the
|
|
693
|
+
* sweep returns before this). invite-expired: an unspent re-bind invite at or past expires_at.
|
|
694
|
+
* enrollment-expired: the 72 h rule now covers enroll, rekey and rebind (5.3): the pending key is
|
|
695
|
+
* revoked for ever, and a re-bind attempt is recorded dead with its pending D1Rs lapsed.
|
|
696
|
+
*/
|
|
697
|
+
export function expireInTx(db, now) {
|
|
698
|
+
const stale = db.prepare(`SELECT e.id, e.kind, e.member_id, e.public_key, e.invite_id FROM enrollments e JOIN keys k USING(public_key)
|
|
699
|
+
WHERE e.confirmed_by IS NULL AND k.status = 'pending' AND e.spent_at + ? <= ?`).all(ENROLLMENT_TTL_MS, now);
|
|
700
|
+
for (const e of stale) {
|
|
701
|
+
if (e.kind === "rebind" && attemptOf(db, e.invite_id)?.completed) continue;
|
|
702
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE public_key = ? AND status = 'pending'").run(now, e.public_key);
|
|
703
|
+
appendLog(db, "enrollment", { kind: "enrollment.expired", enrollment: e.id, enrollment_kind: e.kind, member_id: e.member_id }, now);
|
|
704
|
+
if (e.kind === "rebind" && !db.prepare("SELECT 1 FROM rebind_ends WHERE invite_id = ?").get(e.invite_id))
|
|
705
|
+
recordDeathInTx(db, e.invite_id, "enrollment-expired", null, now, "enrollment-expired");
|
|
706
|
+
}
|
|
707
|
+
const unspent = db.prepare(`SELECT i.invite_id FROM invites i WHERE i.kind = 'rebind' AND i.expires_at <= ? AND i.voided_at IS NULL
|
|
708
|
+
AND NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.invite_id = i.invite_id)
|
|
709
|
+
AND NOT EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = i.invite_id)`).all(now);
|
|
710
|
+
for (const { invite_id } of unspent) recordDeathInTx(db, invite_id, "invite-expired", null, now, "invite-expired");
|
|
711
|
+
return { enrollments: stale.length, invites: unspent.length };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Void an invite after the failure budget (5.2: five failures against one invite). The counting is
|
|
716
|
+
* the request side (enroll.mjs); this is the relay's effect, which the request side calls and tests drive directly. An
|
|
717
|
+
* unspent re-bind invite's attempt is recorded dead (invite-voided).
|
|
718
|
+
*/
|
|
719
|
+
export function voidInvite(db, inviteId, now) {
|
|
720
|
+
return tx(db, () => {
|
|
721
|
+
const i = db.prepare("SELECT * FROM invites WHERE invite_id = ?").get(typeof inviteId === "string" ? inviteId : "");
|
|
722
|
+
if (!i) throw new RelayError("unknown-invite", 404);
|
|
723
|
+
// D1-4: the void path records no death while frozen or while the clock is suspect.
|
|
724
|
+
if (isFrozen(db)) throw new RelayError("frozen", 503);
|
|
725
|
+
if (getMeta(db, "clock_suspect") === "1") throw new RelayError("clock-suspect", 503);
|
|
726
|
+
if (i.voided_at !== null) throw new RelayError("invalid-state", 409, "invite already voided");
|
|
727
|
+
if (db.prepare("SELECT 1 FROM enrollments WHERE invite_id = ?").get(i.invite_id)) throw new RelayError("invalid-state", 409, "invite spent");
|
|
728
|
+
db.prepare("UPDATE invites SET voided_at = ? WHERE invite_id = ?").run(now, i.invite_id);
|
|
729
|
+
alert(db, now, "invite.voided", i.member_id, { invite_id: i.invite_id, reason: "failure-budget" });
|
|
730
|
+
appendLog(db, "enrollment", { kind: "invite.voided", invite_id: i.invite_id, invite_kind: i.kind }, now);
|
|
731
|
+
if (i.kind === "rebind" && !db.prepare("SELECT 1 FROM rebind_ends WHERE invite_id = ?").get(i.invite_id))
|
|
732
|
+
recordDeathInTx(db, i.invite_id, "invite-voided", null, now, "invite-voided");
|
|
733
|
+
return { invite_id: i.invite_id, voided_at: now };
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** A member back to active (no open suspension) has its held submissions back in review. */
|
|
738
|
+
function resumeHeld(db, memberId, now) {
|
|
739
|
+
if (db.prepare("SELECT status FROM members WHERE member_id = ?").get(memberId).status !== "active") return;
|
|
740
|
+
const held = db.prepare("SELECT x.task_id FROM submissions s JOIN submission_details x USING(submission_id) WHERE s.member_id = ? AND s.state = 'held'").all(memberId).map(r => r.task_id);
|
|
741
|
+
db.prepare("UPDATE submissions SET state = 'in-review' WHERE member_id = ? AND state = 'held'").run(memberId);
|
|
742
|
+
for (const t of held) db.prepare("UPDATE tasks SET status = 'in-review', updated_at = ? WHERE task_id = ? AND status = 'held'").run(now, t);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* RD3, 9.6: a member's re-bind count is the number of D1R rebind.confirm decisions
|
|
747
|
+
* about it that became effective (a later owner reversal does not un-count one).
|
|
748
|
+
* Refused, pending, lapsed and withdrawn re-binds do not count.
|
|
749
|
+
*/
|
|
750
|
+
export const rebindCount = (db, memberId) => db.prepare(`SELECT count(*) n FROM decisions d JOIN enrollments e ON e.id = d.subject
|
|
751
|
+
WHERE d.class = 'D1R' AND d.action = 'rebind.confirm' AND d.state IN ('effective','reversed') AND e.kind = 'rebind' AND e.member_id = ?`).get(memberId).n;
|
|
752
|
+
|
|
753
|
+
/** The seat confirmation view of a re-bind (5.4, 9.6): what a seat sees before it signs, including the count so far. */
|
|
754
|
+
export function rebindConfirmationView(db, enrollmentId) {
|
|
755
|
+
const e = db.prepare(`SELECT e.id, e.member_id, e.invite_id, e.public_key, e.spent_at, k.agent, i.created_by
|
|
756
|
+
FROM enrollments e JOIN keys k USING(public_key) JOIN invites i USING(invite_id) WHERE e.id = ? AND e.kind = 'rebind'`).get(enrollmentId);
|
|
757
|
+
if (!e) throw new RelayError("unknown-enrollment", 404);
|
|
758
|
+
const open = db.prepare("SELECT reason FROM suspensions WHERE member_id = ? AND ended_at IS NULL ORDER BY started_at, id").all(e.member_id).map(r => r.reason);
|
|
759
|
+
return {
|
|
760
|
+
enrollment_id: e.id, member_id: e.member_id, invite_id: e.invite_id, invite_created_by: e.created_by,
|
|
761
|
+
new_agent: e.agent, new_public_key: e.public_key, requested_at: e.spent_at,
|
|
762
|
+
rebind_count: rebindCount(db, e.member_id), open_suspensions: open,
|
|
763
|
+
// The member's dead attempts, which never count.
|
|
764
|
+
dead_attempts: db.prepare(`SELECT r.invite_id, r.cause, r.decision_id, r.at FROM rebind_ends r JOIN invites i USING(invite_id)
|
|
765
|
+
WHERE i.member_id = ? ORDER BY r.at, r.invite_id`).all(e.member_id).map(r => ({ ...r })),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Eligibility credit (10, C3, D-C3b): contributions whose D1 accept is currently effective. */
|
|
770
|
+
export function credits(db, memberId) {
|
|
771
|
+
return { ...db.prepare(`SELECT count(*) n, count(DISTINCT c.day) days FROM contributions c JOIN decisions d ON d.id = c.decision_id
|
|
772
|
+
WHERE c.member_id = ? AND d.state = 'effective'`).get(memberId) };
|
|
773
|
+
}
|
|
774
|
+
const qualifies = (db, memberId, role) => {
|
|
775
|
+
const { n, days } = credits(db, memberId);
|
|
776
|
+
return role === "C2" ? n >= 3 && days >= 2 && !hasOpenFinding(db, memberId) : role === "C3" ? n >= 10 : true;
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/** Release a member's live claims (no attempt counted) and reopen their tasks. */
|
|
780
|
+
function releaseClaims(db, claims, reason, now) {
|
|
781
|
+
for (const c of claims) {
|
|
782
|
+
db.prepare("UPDATE claims SET state = 'released', released_reason = ?, released_at = ? WHERE claim_id = ? AND state = 'live'").run(reason, now, c.claim_id);
|
|
783
|
+
db.prepare("UPDATE tasks SET status = 'open', updated_at = ? WHERE task_id = ? AND status = 'claimed'").run(now, c.task_id);
|
|
784
|
+
}
|
|
785
|
+
return claims.map(c => c.claim_id);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* C3 (spec 9.7): the compensating effects of an owner reversal, in the veto's transaction,
|
|
790
|
+
* after the veto is effective and the target is reversed. History stays; every effect is a
|
|
791
|
+
* new row or a forward state change, logged with its cause, plus one owner-visible line.
|
|
792
|
+
*/
|
|
793
|
+
function reverseEffects(db, t, veto, owner, now) {
|
|
794
|
+
const cause = `reversal of ${t.id}`;
|
|
795
|
+
const effects = [];
|
|
796
|
+
const log = (effect, extra = {}) => { effects.push(effect); appendLog(db, "decision", { kind: "reversal.effect", cause, decision: t.id, veto: veto.id, effect, ...extra }, now); };
|
|
797
|
+
switch (t.action) {
|
|
798
|
+
case "accept": {
|
|
799
|
+
// The submission becomes reversed (terminal); its task goes to triage with no attempt counted.
|
|
800
|
+
// Accepted code is never auto-reverted. The accept stops counting toward eligibility at once.
|
|
801
|
+
const s = db.prepare("SELECT s.member_id, x.task_id FROM submissions s JOIN submission_details x USING(submission_id) WHERE s.submission_id = ?").get(t.subject);
|
|
802
|
+
db.prepare("UPDATE submissions SET state = 'reversed' WHERE submission_id = ? AND state = 'accepted'").run(t.subject);
|
|
803
|
+
db.prepare("UPDATE tasks SET status = 'needs-triage', updated_at = ? WHERE task_id = ?").run(now, s.task_id);
|
|
804
|
+
log("submission.reversed", { submission_id: t.subject, task_id: s.task_id });
|
|
805
|
+
const role = roleOf(db, s.member_id);
|
|
806
|
+
if (!qualifies(db, s.member_id, role)) eligibilityLost(db, s.member_id, role, cause, now);
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
case "role.c1": case "role.change": {
|
|
810
|
+
// Re-project from the member's effective role grants (latest wins), as a new row citing the veto.
|
|
811
|
+
const current = roleOf(db, t.subject);
|
|
812
|
+
// S5: a revoked member's role is frozen at revocation. The reversal is
|
|
813
|
+
// still recorded (the target is reversed, this effect is logged, the owner line is written),
|
|
814
|
+
// but no role row is added; the 9.3 trigger roles_frozen_when_revoked would refuse one.
|
|
815
|
+
if (db.prepare("SELECT status FROM members WHERE member_id = ?").get(t.subject).status === "revoked") {
|
|
816
|
+
log("role.unchanged", { role: current, reason: "revoked" });
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
const projected = db.prepare(`SELECT r.role FROM roles r JOIN decisions x ON x.id = r.decision_id WHERE r.member_id = ? AND x.state = 'effective'
|
|
820
|
+
AND x.action IN ('enroll.confirm','role.c1','role.change') ORDER BY r.seq DESC LIMIT 1`).get(t.subject)?.role ?? null;
|
|
821
|
+
if (projected === null || projected === current) { log("role.unchanged", { role: current }); break; }
|
|
822
|
+
const seq = 1 + (db.prepare("SELECT max(seq) s FROM roles WHERE member_id = ?").get(t.subject).s ?? 0);
|
|
823
|
+
db.prepare("INSERT INTO roles(member_id, seq, role, decision_id, at) VALUES (?, ?, ?, ?, ?)").run(t.subject, seq, projected, veto.id, now);
|
|
824
|
+
// A lower role: live claims above the new cap are released, newest first, with no attempt counted.
|
|
825
|
+
const live = db.prepare(`SELECT c.claim_id, c.task_id FROM claims c JOIN claim_details x USING(claim_id)
|
|
826
|
+
WHERE c.member_id = ? AND c.state = 'live' ORDER BY x.created_at DESC, c.rowid DESC`).all(t.subject);
|
|
827
|
+
const over = Math.max(0, live.length - (CLAIM_CAPS[projected] ?? 0));
|
|
828
|
+
log("role.reprojected", { member_id: t.subject, from: current, to: projected, released: releaseClaims(db, live.slice(0, over), "reversal", now) });
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case "reinstate":
|
|
832
|
+
openSuspensionInTx(db, { memberId: t.subject, reason: "decision", decisionId: veto.id, by: owner, now });
|
|
833
|
+
log("suspension.reopened", { member_id: t.subject });
|
|
834
|
+
break;
|
|
835
|
+
case "suspend":
|
|
836
|
+
// That suspension ends (by the veto); held work returns to review. Released claims are not restored.
|
|
837
|
+
if (keylessGuardInTx(db, t.subject, veto, owner, now, "decision_id = ?", [t.id])) log("suspension.keyless", { member_id: t.subject });
|
|
838
|
+
db.prepare("UPDATE suspensions SET ended_at = ?, ended_by_decision = ? WHERE decision_id = ? AND reason = 'decision' AND ended_at IS NULL").run(now, veto.id, t.id);
|
|
839
|
+
resumeHeld(db, t.subject, now);
|
|
840
|
+
log("suspension.ended", { member_id: t.subject });
|
|
841
|
+
break;
|
|
842
|
+
case "enroll.confirm":
|
|
843
|
+
revokeMemberInTx(db, t.subject, now, cause);
|
|
844
|
+
log("member.revoked", { member_id: t.subject });
|
|
845
|
+
break;
|
|
846
|
+
case "rekey.confirm": case "rebind.confirm": {
|
|
847
|
+
// The key that decision bound is revoked; a decision suspension opens. Older keys stay revoked.
|
|
848
|
+
const e = db.prepare("SELECT member_id, public_key FROM enrollments WHERE id = ?").get(t.subject);
|
|
849
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE public_key = ? AND status <> 'revoked'").run(now, e.public_key);
|
|
850
|
+
if (db.prepare("SELECT status FROM members WHERE member_id = ?").get(e.member_id).status !== "revoked")
|
|
851
|
+
openSuspensionInTx(db, { memberId: e.member_id, reason: "decision", decisionId: veto.id, by: owner, now });
|
|
852
|
+
log("key.revoked", { member_id: e.member_id, public_key: e.public_key });
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
case "task.assign": {
|
|
856
|
+
const live = db.prepare("SELECT c.claim_id, c.task_id FROM claim_origins o JOIN claims c USING(claim_id) WHERE o.decision_id = ? AND c.state = 'live'").all(t.id);
|
|
857
|
+
log(live.length ? "claim.released" : "record-only", { released: releaseClaims(db, live, "reversal", now) });
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "invite.create": {
|
|
861
|
+
// Any kind. Completed = record-only (reverse the confirmation
|
|
862
|
+
// itself). Not yet completed: the reversal kills the attempt.
|
|
863
|
+
const inv = db.prepare("SELECT * FROM invites WHERE decision_id = ?").get(t.id);
|
|
864
|
+
const e = db.prepare("SELECT * FROM enrollments WHERE invite_id = ?").get(inv.invite_id);
|
|
865
|
+
const completed = inv.kind === "rebind" ? !!attemptOf(db, inv.invite_id)?.completed : !!e && e.confirmed_by !== null;
|
|
866
|
+
if (completed) { log("record-only", { invite_id: inv.invite_id }); break; }
|
|
867
|
+
if (inv.kind === "rebind") {
|
|
868
|
+
if (!db.prepare("SELECT 1 FROM rebind_ends WHERE invite_id = ?").get(inv.invite_id))
|
|
869
|
+
killAttemptInTx(db, inv.invite_id, "reversed", veto.id, now, `reversal of ${veto.id}`);
|
|
870
|
+
// RC-1: never leave a non-revoked member active without an active key.
|
|
871
|
+
if (keylessGuardInTx(db, inv.member_id, veto, owner, now, "invite_id = ?", [inv.invite_id])) log("suspension.keyless", { member_id: inv.member_id });
|
|
872
|
+
db.prepare("UPDATE suspensions SET ended_at = ?, ended_by_decision = ? WHERE invite_id = ? AND reason = 'rebind' AND ended_at IS NULL").run(now, veto.id, inv.invite_id);
|
|
873
|
+
resumeHeld(db, inv.member_id, now);
|
|
874
|
+
log("attempt.reversed", { invite_id: inv.invite_id, member_id: inv.member_id });
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
if (!e) {
|
|
878
|
+
db.prepare("UPDATE invites SET voided_at = ? WHERE invite_id = ? AND voided_at IS NULL").run(now, inv.invite_id);
|
|
879
|
+
log("invite.voided", { invite_id: inv.invite_id });
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
// Spent, unconfirmed: the pending key is revoked for ever (one key ever).
|
|
883
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE public_key = ? AND status = 'pending'").run(now, e.public_key);
|
|
884
|
+
if (inv.kind === "enroll") {
|
|
885
|
+
revokeMemberInTx(db, e.member_id, now, cause); // the pending member becomes revoked
|
|
886
|
+
log("member.revoked", { invite_id: inv.invite_id, member_id: e.member_id });
|
|
887
|
+
} else log("pending-key.revoked", { invite_id: inv.invite_id, member_id: e.member_id }); // rekey: the current key stays
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
default:
|
|
891
|
+
// task.open, task.close, progress.accept, claim.release, clock.resume and the review outcomes: record-only.
|
|
892
|
+
log("record-only");
|
|
893
|
+
alert(db, now, "reversal.record-only", t.subject, { decision: t.id, action: t.action, veto: veto.id });
|
|
894
|
+
}
|
|
895
|
+
appendLog(db, "security", { kind: "reversal", decision: t.id, action: t.action, veto: veto.id, effects }, now);
|
|
896
|
+
writeOutbox(db, `reversal:${t.id}`, `commons-relay reversal of ${t.id} (${t.action} subject=${t.subject}) by veto=${veto.id}: ${effects.join(", ")} at=${new Date(now).toISOString()}`, now);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** C3: a role whose credit is gone is not changed automatically; the owner and seats are told (security log, alert, owner line). */
|
|
900
|
+
function eligibilityLost(db, memberId, role, cause, now) {
|
|
901
|
+
alert(db, now, "eligibility.lost", memberId, { role, cause });
|
|
902
|
+
appendLog(db, "security", { kind: "eligibility.lost", member_id: memberId, role, cause }, now);
|
|
903
|
+
writeOutbox(db, `eligibility.lost:${memberId}`, `commons-relay eligibility.lost member=${memberId} role=${role} cause=${JSON.stringify(cause)} at=${new Date(now).toISOString()}`, now);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Revoke a member (5.4, 9.4): every key revoked for ever; the 9.3 trigger releases claims and holds submissions. */
|
|
907
|
+
function revokeMemberInTx(db, memberId, now, cause) {
|
|
908
|
+
const released = db.prepare("SELECT task_id FROM claims WHERE member_id = ? AND state = 'live'").all(memberId).map(r => r.task_id);
|
|
909
|
+
const held = db.prepare("SELECT x.task_id FROM submissions s JOIN submission_details x USING(submission_id) WHERE s.member_id = ? AND s.state = 'in-review'").all(memberId).map(r => r.task_id);
|
|
910
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE principal_kind = 'member' AND principal_id = ? AND status <> 'revoked'").run(now, memberId);
|
|
911
|
+
db.prepare("UPDATE members SET status = 'revoked', updated_at = ? WHERE member_id = ?").run(now, memberId);
|
|
912
|
+
for (const t of released) db.prepare("UPDATE tasks SET status = 'open', updated_at = ? WHERE task_id = ? AND status = 'claimed'").run(now, t);
|
|
913
|
+
for (const t of held) db.prepare("UPDATE tasks SET status = 'held', updated_at = ? WHERE task_id = ?").run(now, t);
|
|
914
|
+
db.prepare("UPDATE seat_feed SET state = 'dropped' WHERE member_id = ? AND state = 'pending'").run(memberId);
|
|
915
|
+
alert(db, now, "revocation", memberId, { cause });
|
|
916
|
+
appendLog(db, "security", { kind: "revocation", member_id: memberId, cause }, now);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Mark effective first (the 9.3 role and suspension guards require it), then apply. */
|
|
920
|
+
function effect(db, d, now) {
|
|
921
|
+
db.prepare("UPDATE decisions SET state = 'effective' WHERE id = ?").run(d.id);
|
|
922
|
+
const body = JSON.parse(d.body);
|
|
923
|
+
switch (d.action) {
|
|
924
|
+
case "task.open": {
|
|
925
|
+
const a = taskArgs(body.args);
|
|
926
|
+
db.prepare(`INSERT INTO tasks(task_id, title, description, lane, kind, acceptance, paths, base_commit, status, opened_by, created_at, updated_at)
|
|
927
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?)`)
|
|
928
|
+
.run(d.subject, a.title, a.description, body.lane, a.kind, jcs(a.acceptance), jcs(a.paths), a.base_commit, body.by, now, now);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
case "task.close":
|
|
932
|
+
db.prepare("UPDATE tasks SET status = 'closed', updated_at = ? WHERE task_id = ?").run(now, d.subject);
|
|
933
|
+
break;
|
|
934
|
+
case "claim.release": {
|
|
935
|
+
const c = db.prepare("SELECT task_id FROM claims WHERE claim_id = ? AND state = 'live'").get(d.subject);
|
|
936
|
+
if (c) {
|
|
937
|
+
db.prepare("UPDATE claims SET state = 'released', released_reason = 'seat', released_at = ? WHERE claim_id = ?").run(now, d.subject);
|
|
938
|
+
db.prepare("UPDATE tasks SET status = 'open', updated_at = ? WHERE task_id = ?").run(now, c.task_id);
|
|
939
|
+
}
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
case "task.assign": {
|
|
943
|
+
const m = db.prepare("SELECT role FROM members WHERE member_id = ?").get(body.args.member_id);
|
|
944
|
+
const c = openClaim(db, { taskId: d.subject, memberId: body.args.member_id, role: m.role, leaseMs: body.args.lease_ms, now, decisionId: d.id });
|
|
945
|
+
notify(db, body.args.member_id, `notice-${d.id}`, "claim.assigned", { decision: d.id, ...c }, now);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
case "progress.accept": {
|
|
949
|
+
const pr = db.prepare("SELECT member_id FROM progress WHERE claim_id = ? AND record_id = ?").get(d.subject, body.args.record_id);
|
|
950
|
+
db.prepare("INSERT INTO progress_acceptances(member_id, record_id, decision_id, at) VALUES (?, ?, ?, ?)").run(pr.member_id, body.args.record_id, d.id, now);
|
|
951
|
+
notify(db, pr.member_id, `notice-${d.id}`, "progress.accepted", { decision: d.id, claim_id: d.subject, record_id: body.args.record_id }, now);
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
case "review.changes": {
|
|
955
|
+
// R7 / 11.3: the submission goes to "changes" and the claimant gets a NEW claim
|
|
956
|
+
// (new claim_id, epoch + 1, new lease); the submitted claim stays closed.
|
|
957
|
+
const x = db.prepare(`SELECT x.task_id, s.member_id, d2.lease_ms FROM submission_details x JOIN submissions s USING(submission_id)
|
|
958
|
+
JOIN claim_details d2 ON d2.claim_id = x.claim_id WHERE x.submission_id = ?`).get(d.subject);
|
|
959
|
+
db.prepare("UPDATE submissions SET state = 'changes' WHERE submission_id = ?").run(d.subject);
|
|
960
|
+
const role = db.prepare("SELECT role FROM members WHERE member_id = ?").get(x.member_id).role;
|
|
961
|
+
const c = openClaim(db, { taskId: x.task_id, memberId: x.member_id, role, leaseMs: body.args?.lease_ms ?? x.lease_ms, now, decisionId: d.id });
|
|
962
|
+
notify(db, x.member_id, `notice-${d.id}`, "claim.reissued", { decision: d.id, submission_id: d.subject, ...c }, now);
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
case "review.reject": {
|
|
966
|
+
// 11.2: reject -> open (attempts + 1), or closed; three attempts go to triage.
|
|
967
|
+
const x = db.prepare("SELECT x.task_id, s.member_id FROM submission_details x JOIN submissions s USING(submission_id) WHERE x.submission_id = ?").get(d.subject);
|
|
968
|
+
db.prepare("UPDATE submissions SET state = 'rejected' WHERE submission_id = ?").run(d.subject);
|
|
969
|
+
const attempts = db.prepare("SELECT attempts FROM tasks WHERE task_id = ?").get(x.task_id).attempts + 1;
|
|
970
|
+
const status = body.args.disposition === "close" ? "closed" : attempts >= 3 ? "needs-triage" : "open";
|
|
971
|
+
db.prepare("UPDATE tasks SET attempts = ?, status = ?, updated_at = ? WHERE task_id = ?").run(attempts, status, now, x.task_id);
|
|
972
|
+
notify(db, x.member_id, `notice-${d.id}`, "submission.rejected", { decision: d.id, submission_id: d.subject, task_status: status }, now);
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
case "clock.resume":
|
|
976
|
+
setMeta(db, "clock_suspect", "0");
|
|
977
|
+
setMeta(db, "clock_high_water", String(now));
|
|
978
|
+
appendLog(db, "security", { kind: "clock.resumed", decision: d.id }, now);
|
|
979
|
+
break;
|
|
980
|
+
case "role.c1": case "role.change": {
|
|
981
|
+
const seq = 1 + (db.prepare("SELECT max(seq) s FROM roles WHERE member_id = ?").get(d.subject).s ?? 0);
|
|
982
|
+
db.prepare("INSERT INTO roles(member_id, seq, role, decision_id, at) VALUES (?, ?, ?, ?, ?)")
|
|
983
|
+
.run(d.subject, seq, d.action === "role.c1" ? "C1" : body.args.role, d.id, now);
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case "accept": {
|
|
987
|
+
const x = db.prepare("SELECT x.task_id, s.member_id FROM submission_details x JOIN submissions s USING(submission_id) WHERE x.submission_id = ?").get(d.subject);
|
|
988
|
+
db.prepare("UPDATE submissions SET state = 'accepted' WHERE submission_id = ?").run(d.subject);
|
|
989
|
+
db.prepare("UPDATE tasks SET status = 'accepted', updated_at = ? WHERE task_id = ?").run(now, x.task_id);
|
|
990
|
+
db.prepare("INSERT INTO contributions(submission_id, member_id, decision_id, accepted_at, day) VALUES (?, ?, ?, ?, ?)")
|
|
991
|
+
.run(d.subject, x.member_id, d.id, now, new Date(now).toISOString().slice(0, 10));
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
case "suspend":
|
|
995
|
+
openSuspensionInTx(db, { memberId: d.subject, reason: "decision", decisionId: d.id, by: body.by, now });
|
|
996
|
+
break;
|
|
997
|
+
case "reinstate": {
|
|
998
|
+
// D3: first kill any live attempt (cause reinstated: the higher class wins), then end every open
|
|
999
|
+
// decision suspension and every open re-bind suspension of a dead attempt. The 9.3 trigger
|
|
1000
|
+
// reactivates the member when none is open.
|
|
1001
|
+
const live = liveAttempt(db, d.subject);
|
|
1002
|
+
if (live) killAttemptInTx(db, live.invite_id, "reinstated", d.id, now, "reinstated");
|
|
1003
|
+
db.prepare("UPDATE suspensions SET ended_at = ?, ended_by_decision = ? WHERE member_id = ? AND reason = 'decision' AND ended_at IS NULL").run(now, d.id, d.subject);
|
|
1004
|
+
endDeadRebindSuspensionsInTx(db, d.subject, d.id, now);
|
|
1005
|
+
resumeHeld(db, d.subject, now);
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
case "rebind.cancel":
|
|
1009
|
+
killAttemptInTx(db, d.subject, "cancelled", d.id, now, "rebind-cancelled");
|
|
1010
|
+
break;
|
|
1011
|
+
case "invite.create": {
|
|
1012
|
+
// 5.1: the relay makes a 128-bit invite id and a 256-bit secret, stores only sha256(secret),
|
|
1013
|
+
// and returns the secret once, to the creating call. A rebind invite suspends the member now (C4).
|
|
1014
|
+
const a = body.args, inviteId = randomBytes(16).toString("hex"), secret = randomBytes(32);
|
|
1015
|
+
const memberId = a.kind === "enroll" ? null : a.member_id;
|
|
1016
|
+
// The D items: invites.decision_id binds the invite to its signed invite.create (9.3 invites_need_decision).
|
|
1017
|
+
db.prepare("INSERT INTO invites(invite_id, kind, member_id, created_by, decision_id, secret_sha256, created_at, expires_at, voided_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)")
|
|
1018
|
+
.run(inviteId, a.kind, memberId, body.by, d.id, sha256Hex(secret), now, now + INVITE_TTL_MS);
|
|
1019
|
+
db.prepare("INSERT INTO invite_details(invite_id, decision_id, lane, intended_display_name, intended_platform) VALUES (?, ?, ?, ?, ?)")
|
|
1020
|
+
.run(inviteId, d.id, body.lane, a.intended_display_name, a.intended_platform);
|
|
1021
|
+
if (a.kind === "rebind") {
|
|
1022
|
+
// D3 retry (supersession): open the new attempt's suspension FIRST, then end every dead
|
|
1023
|
+
// attempt's open suspension citing this invite.create, so the member is never active in between.
|
|
1024
|
+
openSuspensionInTx(db, { memberId, reason: "rebind", inviteId, by: body.by, now });
|
|
1025
|
+
endDeadRebindSuspensionsInTx(db, memberId, d.id, now);
|
|
1026
|
+
}
|
|
1027
|
+
appendLog(db, "enrollment", { kind: "invite.created", invite_id: inviteId, invite_kind: a.kind, member_id: memberId, decision: d.id, by: body.by }, now);
|
|
1028
|
+
inviteOut = { invite_id: inviteId, secret: secret.toString("hex"), kind: a.kind, member_id: memberId, expires_at: now + INVITE_TTL_MS, namespace: "agentnet" };
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
case "enroll.confirm": {
|
|
1032
|
+
const e = pendingEnrollment(db, "enroll", d.subject);
|
|
1033
|
+
db.prepare("UPDATE enrollments SET confirmed_by = ?, confirmed_at = ?, owner_line_ref = ? WHERE id = ?").run(body.by, now, body.args.owner_line_ref, e.id);
|
|
1034
|
+
db.prepare("UPDATE keys SET status = 'active' WHERE public_key = ?").run(e.public_key);
|
|
1035
|
+
db.prepare("UPDATE members SET status = 'active', updated_at = ? WHERE member_id = ?").run(now, d.subject);
|
|
1036
|
+
const seq = 1 + (db.prepare("SELECT max(seq) s FROM roles WHERE member_id = ?").get(d.subject).s ?? 0);
|
|
1037
|
+
db.prepare("INSERT INTO roles(member_id, seq, role, decision_id, at) VALUES (?, ?, 'C0', ?, ?)").run(d.subject, seq, d.id, now);
|
|
1038
|
+
appendLog(db, "enrollment", { kind: "enroll.confirmed", member_id: d.subject, enrollment: e.id, decision: d.id, by: body.by }, now);
|
|
1039
|
+
alert(db, now, "enrollment", d.subject, { enrollment: e.id, decision: d.id, by: body.by, invite_created_by: e.created_by });
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
case "rekey.confirm": {
|
|
1043
|
+
// 5.4: the old key is revoked; the new key becomes the member's one active key.
|
|
1044
|
+
const e = db.prepare("SELECT * FROM enrollments WHERE id = ?").get(d.subject);
|
|
1045
|
+
db.prepare("UPDATE enrollments SET confirmed_by = ?, confirmed_at = ?, owner_line_ref = ? WHERE id = ?").run(body.by, now, body.args.owner_line_ref, e.id);
|
|
1046
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE principal_kind = 'member' AND principal_id = ? AND public_key <> ? AND status <> 'revoked'")
|
|
1047
|
+
.run(now, e.member_id, e.public_key);
|
|
1048
|
+
db.prepare("UPDATE keys SET status = 'active' WHERE public_key = ?").run(e.public_key);
|
|
1049
|
+
appendLog(db, "security", { kind: "rekey.confirmed", member_id: e.member_id, enrollment: e.id, decision: d.id, by: body.by }, now);
|
|
1050
|
+
alert(db, now, "rekey", e.member_id, { enrollment: e.id, decision: d.id, by: body.by });
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
case "rebind.confirm": {
|
|
1054
|
+
// 5.4: end only this re-bind's suspension (RD2: a decision suspension stays until a D2
|
|
1055
|
+
// reinstate), revoke every older key, and make the re-bound key the member's one active key.
|
|
1056
|
+
const e = db.prepare("SELECT id, member_id, invite_id, public_key FROM enrollments WHERE id = ?").get(d.subject);
|
|
1057
|
+
db.prepare("UPDATE suspensions SET ended_at = ?, ended_by_decision = ? WHERE member_id = ? AND reason = 'rebind' AND invite_id = ? AND ended_at IS NULL")
|
|
1058
|
+
.run(now, d.id, e.member_id, e.invite_id);
|
|
1059
|
+
db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE principal_kind = 'member' AND principal_id = ? AND public_key <> ? AND status <> 'revoked'")
|
|
1060
|
+
.run(now, e.member_id, e.public_key);
|
|
1061
|
+
db.prepare("UPDATE keys SET status = 'active' WHERE public_key = ?").run(e.public_key);
|
|
1062
|
+
resumeHeld(db, e.member_id, now);
|
|
1063
|
+
// RD3, 9.6: the count is recorded; from the second effective re-bind on, exactly one
|
|
1064
|
+
// rebind.repeat alert per (member, count). The seat decisions are made in this module.
|
|
1065
|
+
const count = rebindCount(db, e.member_id);
|
|
1066
|
+
appendLog(db, "security", { kind: "rebind.confirmed", member_id: e.member_id, enrollment: e.id, decision: d.id, count }, now);
|
|
1067
|
+
if (count >= 2 && !db.prepare("SELECT 1 FROM alerts WHERE kind = 'rebind.repeat' AND subject = ? AND json_extract(detail, '$.count') = ?").get(e.member_id, count)) {
|
|
1068
|
+
alert(db, now, "rebind.repeat", e.member_id, { count, decision: d.id });
|
|
1069
|
+
appendLog(db, "security", { kind: "rebind.repeat", member_id: e.member_id, count }, now);
|
|
1070
|
+
}
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case "veto": {
|
|
1074
|
+
const target = db.prepare("SELECT * FROM decisions WHERE id = ?").get(d.subject);
|
|
1075
|
+
if (target.state === "pending") { transition(db, target, "vetoed", now); break; }
|
|
1076
|
+
transition(db, target, "reversed", now);
|
|
1077
|
+
reverseEffects(db, target, d, body.by, now);
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
case "freeze":
|
|
1081
|
+
setMeta(db, "frozen", "1");
|
|
1082
|
+
setMeta(db, "frozen_at", String(now));
|
|
1083
|
+
alert(db, now, "freeze", null, { decision: d.id, by: body.by });
|
|
1084
|
+
break;
|
|
1085
|
+
case "unfreeze": {
|
|
1086
|
+
// On unfreeze every live lease is extended by the frozen interval (9.2 Freeze).
|
|
1087
|
+
const interval = Math.max(0, now - Number(getMeta(db, "frozen_at") ?? now));
|
|
1088
|
+
db.prepare("UPDATE claims SET lease_until = lease_until + ? WHERE state = 'live'").run(interval);
|
|
1089
|
+
setMeta(db, "frozen", "0");
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
appendLog(db, "decision", { kind: "decision.effective", id: d.id, action: d.action, subject: d.subject }, now);
|
|
1094
|
+
return "effective";
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Re-evaluate every pending decision: lapse after 14 days, D2 effect after its window. */
|
|
1098
|
+
export function settleAll(db, now) {
|
|
1099
|
+
return tx(db, () => db.prepare("SELECT id FROM decisions WHERE state = 'pending' ORDER BY created_at, id").all()
|
|
1100
|
+
.map(({ id }) => ({ id, state: settleInTx(db, id, now) })));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// --- verification lane, security reviewer, co-authors, owner record ---------
|
|
1104
|
+
|
|
1105
|
+
export function recordVerifyResult(db, signed, now) {
|
|
1106
|
+
return tx(db, () => {
|
|
1107
|
+
const { body, sig, principal } = verifySigned(db, signed, VERIFY_V, now);
|
|
1108
|
+
refuseIfFrozen(db, "verify.result");
|
|
1109
|
+
requirePermission(principal, "verify.result");
|
|
1110
|
+
const allowed = ["v", "submission_id", "base_commit", "patch_sha256", "image_digest", "network", "checks", "exit_codes", "log_sha256", "by", "served_by", "at"];
|
|
1111
|
+
for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1112
|
+
const s = db.prepare("SELECT x.*, t.base_commit FROM submission_details x JOIN tasks t USING(task_id) WHERE x.submission_id = ?").get(body.submission_id ?? "");
|
|
1113
|
+
if (!s || s.kind !== "patch") throw new RelayError("invalid-state", 409, "no patch submission");
|
|
1114
|
+
if (body.base_commit !== s.base_commit || body.patch_sha256 !== s.body_sha256) throw new RelayError("verify-mismatch", 409);
|
|
1115
|
+
if (body.network !== "none") throw new RelayError("verify-network-not-cut", 409);
|
|
1116
|
+
const checks = body.checks, codes = body.exit_codes;
|
|
1117
|
+
if (!Array.isArray(checks) || !Array.isArray(codes) || !checks.length || checks.length !== codes.length || codes.some(c => !Number.isSafeInteger(c)))
|
|
1118
|
+
throw new RelayError("invalid-field", 400, "checks/exit_codes");
|
|
1119
|
+
// Pass is derived from the exit codes, never from a claimed flag.
|
|
1120
|
+
const passed = codes.every(c => c === 0) ? 1 : 0;
|
|
1121
|
+
// N5: one result per verifier and submission; a second is a relay error, not a raw SQLite one.
|
|
1122
|
+
if (db.prepare("SELECT 1 FROM verify_results WHERE submission_id = ? AND verifier = ?").get(s.submission_id, principal.principal_id))
|
|
1123
|
+
throw new RelayError("duplicate-verify-result", 409);
|
|
1124
|
+
db.prepare("INSERT INTO verify_results(submission_id, verifier, passed, patch_sha256, served_by, at, body, sig) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
1125
|
+
.run(s.submission_id, principal.principal_id, passed, body.patch_sha256, body.served_by, now, jcs(body), sig);
|
|
1126
|
+
appendLog(db, "decision", { kind: "verify.result", submission_id: s.submission_id, by: principal.principal_id, passed }, now);
|
|
1127
|
+
settlePendingFor(db, s.submission_id, now);
|
|
1128
|
+
return { submission_id: s.submission_id, passed: passed === 1 };
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export function recordSecurityApproval(db, signed, now) {
|
|
1133
|
+
return tx(db, () => {
|
|
1134
|
+
const { body, sig, principal } = verifySigned(db, signed, SECURITY_V, now);
|
|
1135
|
+
refuseIfFrozen(db, "security.approve");
|
|
1136
|
+
requirePermission(principal, "security.approve");
|
|
1137
|
+
for (const key of Object.keys(body)) if (!["v", "submission_id", "patch_sha256", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1138
|
+
const s = db.prepare("SELECT * FROM submission_details WHERE submission_id = ?").get(body.submission_id ?? "");
|
|
1139
|
+
if (!s || body.patch_sha256 !== s.body_sha256) throw new RelayError("invalid-state", 409, "submission/patch");
|
|
1140
|
+
db.prepare("INSERT INTO security_approvals(submission_id, reviewer, served_by, at, body, sig) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1141
|
+
.run(s.submission_id, principal.principal_id, body.served_by, now, jcs(body), sig);
|
|
1142
|
+
appendLog(db, "decision", { kind: "security.approve", submission_id: s.submission_id, by: principal.principal_id }, now);
|
|
1143
|
+
settlePendingFor(db, s.submission_id, now);
|
|
1144
|
+
return { submission_id: s.submission_id };
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** A fleet principal that contributed to a submission declares it before any decision about it (V5). */
|
|
1149
|
+
export function declareCoauthor(db, signed, now) {
|
|
1150
|
+
return tx(db, () => {
|
|
1151
|
+
const { body, sig, principal } = verifySigned(db, signed, COAUTHOR_V, now);
|
|
1152
|
+
refuseIfFrozen(db, "coauthor.declare");
|
|
1153
|
+
requirePermission(principal, "coauthor.declare");
|
|
1154
|
+
for (const key of Object.keys(body)) if (!["v", "submission_id", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1155
|
+
if (!db.prepare("SELECT 1 FROM submissions WHERE submission_id = ?").get(body.submission_id ?? "")) throw new RelayError("unknown-submission", 404);
|
|
1156
|
+
try {
|
|
1157
|
+
db.prepare("INSERT INTO coauthors(submission_id, principal, served_by, at, body, sig) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1158
|
+
.run(body.submission_id, principal.principal_id, body.served_by, now, jcs(body), sig);
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
if (error.message.includes("coauthors-closed")) throw new RelayError("coauthors-closed", 409);
|
|
1161
|
+
throw error;
|
|
1162
|
+
}
|
|
1163
|
+
appendLog(db, "decision", { kind: "coauthor.declare", submission_id: body.submission_id, by: principal.principal_id }, now);
|
|
1164
|
+
return { submission_id: body.submission_id, coauthor: principal.principal_id };
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function settlePendingFor(db, submissionId, now) {
|
|
1169
|
+
for (const { id } of db.prepare("SELECT id FROM decisions WHERE subject = ? AND state = 'pending'").all(submissionId)) settleInTx(db, id, now);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** The seat-side mirror confirms the owner-visible line was posted (9.5). Only this sets owner_posted_at. */
|
|
1173
|
+
export function confirmOwnerRecord(db, signed, now) {
|
|
1174
|
+
return tx(db, () => {
|
|
1175
|
+
const { body, sig, principal } = verifySigned(db, signed, OWNER_CONFIRM_V, now);
|
|
1176
|
+
requirePermission(principal, "owner-record.confirm");
|
|
1177
|
+
for (const key of Object.keys(body)) if (!["v", "outbox_id", "line_sha256", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1178
|
+
const row = db.prepare("SELECT * FROM owner_record_outbox WHERE id = ?").get(body.outbox_id ?? "");
|
|
1179
|
+
if (!row || row.line_sha256 !== body.line_sha256) throw new RelayError("owner-record-mismatch", 409);
|
|
1180
|
+
if (row.confirmed_at !== null) throw new RelayError("owner-record-already-confirmed", 409);
|
|
1181
|
+
db.prepare("UPDATE owner_record_outbox SET confirmed_at = ?, confirmed_by = ? WHERE id = ?").run(now, principal.principal_id, row.id);
|
|
1182
|
+
db.prepare("INSERT INTO owner_record_confirmations(outbox_id, confirmed_by, at, body, sig) VALUES (?, ?, ?, ?, ?)").run(row.id, principal.principal_id, now, jcs(body), sig);
|
|
1183
|
+
// Every confirmation has its own decision-log event: the export check reads its acceptance time there.
|
|
1184
|
+
appendLog(db, "decision", { kind: "owner-record.confirm", outbox_id: row.id, subject: row.subject, by: principal.principal_id }, now);
|
|
1185
|
+
const d = db.prepare("SELECT * FROM decisions WHERE id = ?").get(row.subject);
|
|
1186
|
+
if (d && d.owner_posted_at === null) {
|
|
1187
|
+
db.prepare("UPDATE decisions SET owner_line_ref = ?, owner_posted_at = ?, effect_at = ? WHERE id = ?")
|
|
1188
|
+
.run(row.id, now, d.class === "D2" ? now + D2_WINDOW_MS : null, d.id);
|
|
1189
|
+
appendLog(db, "decision", { kind: "owner-record.confirmed", id: d.id, outbox_id: row.id }, now);
|
|
1190
|
+
settleInTx(db, d.id, now);
|
|
1191
|
+
}
|
|
1192
|
+
return { outbox_id: row.id };
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// --- runtime evidence (C5, spec 9.8) ----------------------------------------
|
|
1197
|
+
|
|
1198
|
+
const ATTEST_KEYS = ["v", "target", "session_id", "record_uuid", "response_model", "turn_models", "family", "body_sha256", "tool", "tool_sha256",
|
|
1199
|
+
"measured_at", "by", "served_by", "at"];
|
|
1200
|
+
const ATTEST_ERRORS = ["attest-not-measurer", "attest-body-mismatch", "attest-family-map", "attest-mixed-turn", "measurer-conflict"];
|
|
1201
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* The measurer files a signed served_by.attest for one verdict (or a creator's implicit
|
|
1205
|
+
* seq-1 approve). The 9.3 triggers enforce rules 1-4 (measurer role, target body hash,
|
|
1206
|
+
* the policy's model map, an unmixed turn); this checks the per-decision exclusion
|
|
1207
|
+
* (the measurer never votes on, authors, sponsors or created the target decision) and
|
|
1208
|
+
* rule 5: an attested family other than the seat's asserted one earns nothing and
|
|
1209
|
+
* writes a served_by.mismatch security alert. The decision is then re-evaluated.
|
|
1210
|
+
*/
|
|
1211
|
+
export function recordAttestation(db, signed, now) {
|
|
1212
|
+
return tx(db, () => {
|
|
1213
|
+
const { body, sig, principal } = verifySigned(db, signed, ATTEST_V, now);
|
|
1214
|
+
refuseIfFrozen(db, "served_by.attest");
|
|
1215
|
+
requirePermission(principal, "served_by.attest");
|
|
1216
|
+
for (const key of Object.keys(body)) if (!ATTEST_KEYS.includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1217
|
+
const t = body.target;
|
|
1218
|
+
if (!t || typeof t !== "object" || Array.isArray(t) || Object.keys(t).some(k => !["decision_id", "by", "seq"].includes(k))
|
|
1219
|
+
|| !isRecordId(t.decision_id) || typeof t.by !== "string" || !Number.isSafeInteger(t.seq) || t.seq < 1) throw new RelayError("invalid-field", 400, "target");
|
|
1220
|
+
const text = (field, v) => sanitizeText(v, { field, max: 128 });
|
|
1221
|
+
for (const field of ["session_id", "record_uuid", "response_model", "family"]) if (typeof body[field] !== "string" || !text(field, body[field]).length) throw new RelayError("invalid-field", 400, field);
|
|
1222
|
+
if (!Array.isArray(body.turn_models) || !body.turn_models.length || body.turn_models.length > 64
|
|
1223
|
+
|| body.turn_models.some((m, i) => typeof m !== "string" || !text(`turn_models[${i}]`, m).length)) throw new RelayError("invalid-field", 400, "turn_models");
|
|
1224
|
+
if (!HEX64.test(body.body_sha256 ?? "") || !HEX64.test(body.tool_sha256 ?? "")) throw new RelayError("invalid-field", 400, "body_sha256/tool_sha256");
|
|
1225
|
+
if (body.tool !== "model-of") throw new RelayError("invalid-field", 400, "tool");
|
|
1226
|
+
if (!Number.isSafeInteger(body.measured_at)) throw new RelayError("invalid-field", 400, "measured_at");
|
|
1227
|
+
const d = db.prepare("SELECT id, sponsor, body FROM decisions WHERE id = ?").get(t.decision_id);
|
|
1228
|
+
if (!d) throw new RelayError("unknown-decision", 404);
|
|
1229
|
+
const m = principal.principal_id;
|
|
1230
|
+
if (d.sponsor === m || JSON.parse(d.body).by === m
|
|
1231
|
+
|| db.prepare("SELECT 1 FROM approvals WHERE decision_id = ? AND approver = ?").get(d.id, m)
|
|
1232
|
+
|| db.prepare("SELECT 1 FROM decision_authors WHERE decision_id = ? AND principal = ?").get(d.id, m)) throw new RelayError("measurer-conflict", 409);
|
|
1233
|
+
const target = db.prepare("SELECT served_by FROM approvals WHERE decision_id = ? AND approver = ? AND seq = ?").get(d.id, t.by, t.seq);
|
|
1234
|
+
if (!target) throw new RelayError("invalid-state", 409, "no such verdict");
|
|
1235
|
+
const asserted = measuredFamily(target.served_by);
|
|
1236
|
+
// D9.3: the response model must match EXACTLY ONE pattern of the current policy's family map
|
|
1237
|
+
// (glob on the whole name, case-sensitive). Zero or several matches: no credit and a
|
|
1238
|
+
// served_by.mismatch alert. The attestation itself is not stored (the 9.3 attest_family_map
|
|
1239
|
+
// trigger refuses it), but the alert is, so this returns instead of throwing.
|
|
1240
|
+
const policyVersion = Number(getMeta(db, "policy_version") ?? 0);
|
|
1241
|
+
const matches = db.prepare("SELECT family FROM policy_family_map WHERE policy_version = ? AND ? GLOB pattern").all(policyVersion, body.response_model);
|
|
1242
|
+
if (matches.length !== 1) {
|
|
1243
|
+
const detail = { decision: d.id, seq: t.seq, asserted, attested: body.family, response_model: body.response_model, map_matches: matches.length };
|
|
1244
|
+
alert(db, now, "served_by.mismatch", t.by, detail);
|
|
1245
|
+
appendLog(db, "security", { kind: "served_by.mismatch", decision_id: d.id, by: t.by, seq: t.seq, asserted, attested: body.family, map_matches: matches.length }, now);
|
|
1246
|
+
return { id: null, credited: false, refused: "attest-family-map", state: db.prepare("SELECT state FROM decisions WHERE id = ?").get(d.id).state };
|
|
1247
|
+
}
|
|
1248
|
+
const id = randomUUID();
|
|
1249
|
+
try {
|
|
1250
|
+
db.prepare(`INSERT INTO served_by_attestations(id, decision_id, target_by, target_seq, measurer, session_id, record_uuid, response_model, turn_models,
|
|
1251
|
+
family, body_sha256, tool, tool_sha256, measured_at, policy_version, recorded_at, body, sig)
|
|
1252
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1253
|
+
.run(id, d.id, t.by, t.seq, m, body.session_id, body.record_uuid, body.response_model, JSON.stringify(body.turn_models), body.family,
|
|
1254
|
+
body.body_sha256, body.tool, body.tool_sha256, body.measured_at, policyVersion, now, jcs(body), sig);
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
const known = ATTEST_ERRORS.find(c => error.message.includes(c));
|
|
1257
|
+
if (known) throw new RelayError(known, 409);
|
|
1258
|
+
if (/UNIQUE/.test(error.message)) throw new RelayError("duplicate-attestation", 409);
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1261
|
+
const credited = asserted === body.family;
|
|
1262
|
+
if (!credited) {
|
|
1263
|
+
alert(db, now, "served_by.mismatch", t.by, { decision: d.id, seq: t.seq, asserted, attested: body.family });
|
|
1264
|
+
appendLog(db, "security", { kind: "served_by.mismatch", decision_id: d.id, by: t.by, seq: t.seq, asserted, attested: body.family }, now);
|
|
1265
|
+
}
|
|
1266
|
+
appendLog(db, "decision", { kind: "served_by.attest", id, decision_id: d.id, by: t.by, seq: t.seq, family: body.family, credited }, now);
|
|
1267
|
+
return { id, credited, state: settleInTx(db, d.id, now) };
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// --- security findings (section 10: C2 needs none open) -----------------------
|
|
1272
|
+
|
|
1273
|
+
/** The security reviewer opens a finding against a member. */
|
|
1274
|
+
export function openFinding(db, signed, now) {
|
|
1275
|
+
return tx(db, () => {
|
|
1276
|
+
const { body, sig, principal } = verifySigned(db, signed, FINDING_OPEN_V, now);
|
|
1277
|
+
refuseIfFrozen(db, "finding.open");
|
|
1278
|
+
requirePermission(principal, "finding.open");
|
|
1279
|
+
for (const key of Object.keys(body)) if (!["v", "id", "member_id", "summary", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1280
|
+
if (!isRecordId(body.id)) throw new RelayError("invalid-field", 400, "id");
|
|
1281
|
+
sanitizeText(body.summary, { field: "summary", max: 4096 });
|
|
1282
|
+
if (!db.prepare("SELECT 1 FROM members WHERE member_id = ?").get(body.member_id ?? "")) throw new RelayError("unknown-member", 404);
|
|
1283
|
+
try {
|
|
1284
|
+
db.prepare("INSERT INTO security_findings(id, member_id, opened_by, opened_at, body, sig) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1285
|
+
.run(body.id, body.member_id, principal.principal_id, now, jcs(body), sig);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
if (/UNIQUE|PRIMARY KEY/.test(error.message)) throw new RelayError("duplicate-finding", 409);
|
|
1288
|
+
throw error;
|
|
1289
|
+
}
|
|
1290
|
+
alert(db, now, "security-finding", body.member_id, { finding: body.id, by: principal.principal_id });
|
|
1291
|
+
appendLog(db, "security", { kind: "finding.opened", finding: body.id, member_id: body.member_id, by: principal.principal_id }, now);
|
|
1292
|
+
return { id: body.id, member_id: body.member_id };
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** The security reviewer closes a finding, once. Pending decisions about the member are evaluated again. */
|
|
1297
|
+
export function closeFinding(db, signed, now) {
|
|
1298
|
+
return tx(db, () => {
|
|
1299
|
+
const { body, sig, principal } = verifySigned(db, signed, FINDING_CLOSE_V, now);
|
|
1300
|
+
refuseIfFrozen(db, "finding.close");
|
|
1301
|
+
requirePermission(principal, "finding.close");
|
|
1302
|
+
for (const key of Object.keys(body)) if (!["v", "finding_id", "resolution", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1303
|
+
sanitizeText(body.resolution, { field: "resolution", max: 4096 });
|
|
1304
|
+
const f = db.prepare("SELECT * FROM security_findings WHERE id = ?").get(body.finding_id ?? "");
|
|
1305
|
+
if (!f) throw new RelayError("unknown-finding", 404);
|
|
1306
|
+
if (f.closed_at !== null) throw new RelayError("finding-already-closed", 409);
|
|
1307
|
+
db.prepare("UPDATE security_findings SET closed_by = ?, closed_at = ?, close_body = ?, close_sig = ? WHERE id = ?").run(principal.principal_id, now, jcs(body), sig, f.id);
|
|
1308
|
+
appendLog(db, "security", { kind: "finding.closed", finding: f.id, member_id: f.member_id, by: principal.principal_id }, now);
|
|
1309
|
+
for (const { id } of db.prepare("SELECT id FROM decisions WHERE subject = ? AND state = 'pending'").all(f.member_id)) settleInTx(db, id, now);
|
|
1310
|
+
return { id: f.id, closed: true };
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// --- seat messages ----------------------------------------------------------------
|
|
1315
|
+
|
|
1316
|
+
/** An AC seat sends a signed message to one member of its lane (7.5). */
|
|
1317
|
+
export function sendSeatMessage(db, signed, now) {
|
|
1318
|
+
return tx(db, () => {
|
|
1319
|
+
const { body, principal } = verifySigned(db, signed, SEAT_MESSAGE_V, now);
|
|
1320
|
+
requirePermission(principal, "message.send");
|
|
1321
|
+
refuseIfFrozen(db, "message.send");
|
|
1322
|
+
for (const key of Object.keys(body)) if (!["v", "record_id", "member_id", "lane", "text", "by", "served_by", "at"].includes(key)) throw new RelayError("unknown-field", 400, key);
|
|
1323
|
+
const lane = db.prepare("SELECT 1 FROM member_profiles WHERE member_id = ? AND lane = ?").get(body.member_id ?? "", body.lane ?? "");
|
|
1324
|
+
if (!lane) throw new RelayError("unknown-recipient", 404);
|
|
1325
|
+
const text = sanitizeText(body.text, { field: "text", max: 8192 });
|
|
1326
|
+
return deliverInTx(db, body.member_id, { record_id: body.record_id, sender: principal.principal_id, type: "seat-message", body: { lane: body.lane, text } }, now);
|
|
1327
|
+
});
|
|
1328
|
+
}
|