@uuaid/agent-commons 0.0.0 → 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 +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- 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 +359 -0
- 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/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
// Cloud Commons relay: relay.db schema.
|
|
2
|
+
//
|
|
3
|
+
// CORE_DDL is the section 9.3 block of the spec, copied byte for byte (the bytes after the
|
|
4
|
+
// newline that ends the sql fence line, through the newline before the closing fence; see
|
|
5
|
+
// SPEC_PIN for the v3.4 pin). It is the lifecycle-wide invariant
|
|
6
|
+
// schema: append-only governed tables, immutable identity columns, set-once
|
|
7
|
+
// fields, foreign keys, STRICT integer milliseconds. EXTENSION_DDL adds the
|
|
8
|
+
// board, mailbox and log tables beside it without changing any 9.3 table.
|
|
9
|
+
|
|
10
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { basename, dirname } from "node:path";
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
export const SCHEMA_VERSION = 9;
|
|
16
|
+
// CORE_DDL is the section 9.3 block of spec v3.4.6 (pinned at commit f49c073, which rewords only the 11.3
|
|
17
|
+
// sweep sentence over b44ce70; the 9.3 block is unchanged). Its statements are those of the
|
|
18
|
+
// pinned DDL file for v3.4.2 (ddl_ruling_sha256 below: v3.3.2 + the D items + RC-2 + the D1-3 CHECKs, plus the
|
|
19
|
+
// trigger roles_frozen_when_revoked, S5: no role row for a revoked member); v3.4.6 rewords two of its
|
|
20
|
+
// comment lines for publication and changes no statement.
|
|
21
|
+
export const SPEC_PIN = Object.freeze({
|
|
22
|
+
commit: "f49c073", sha256: "ebfb801d7fa71e0752693224bdd99deda05d7bd05ba5a059e7da0407e5bd7f20",
|
|
23
|
+
ddl_ruling: "scope ruling D-2", ddl_ruling_sha256: "e7faf79a0cad3bd554901dedddffa2d64945bb22d1b399077e3520de06eed022",
|
|
24
|
+
core_ddl_sha256: "ca400877c714c0143479743d1704935501f48dfd75241b8940ab86e2ca017b14",
|
|
25
|
+
});
|
|
26
|
+
export const D2_WINDOW_MS = 86400000;
|
|
27
|
+
|
|
28
|
+
export const CORE_DDL = `-- relay.db invariant schema (L0). Every connection runs PRAGMA foreign_keys = ON;
|
|
29
|
+
-- the store reads it back and refuses to open if it is not 1. Times are integer
|
|
30
|
+
-- milliseconds since the Unix epoch (UTC), set by the reducer from the relay clock.
|
|
31
|
+
-- STRICT tables refuse values of the wrong type.
|
|
32
|
+
|
|
33
|
+
CREATE TABLE fleet_principals(
|
|
34
|
+
principal_id TEXT PRIMARY KEY,
|
|
35
|
+
kind TEXT NOT NULL CHECK (kind IN ('ac-seat','verifier','security','scope','owner','measurer')),
|
|
36
|
+
runtime_family TEXT,
|
|
37
|
+
status TEXT NOT NULL CHECK (status IN ('active','retired'))
|
|
38
|
+
) STRICT;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE members(
|
|
41
|
+
member_id TEXT PRIMARY KEY,
|
|
42
|
+
status TEXT NOT NULL CHECK (status IN ('pending','active','suspended','revoked')),
|
|
43
|
+
role TEXT CHECK (role IN ('C0','C1','C2','C3')), -- projection of roles; see roles_project
|
|
44
|
+
updated_at INTEGER NOT NULL
|
|
45
|
+
) STRICT;
|
|
46
|
+
CREATE TRIGGER members_start_pending BEFORE INSERT ON members
|
|
47
|
+
WHEN NEW.status <> 'pending' OR NEW.role IS NOT NULL
|
|
48
|
+
BEGIN SELECT RAISE(ABORT, 'member-starts-pending'); END;
|
|
49
|
+
CREATE TRIGGER members_no_delete BEFORE DELETE ON members
|
|
50
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
51
|
+
-- status 'suspended' holds exactly while an open suspension exists
|
|
52
|
+
CREATE TRIGGER members_status_follows_suspensions BEFORE UPDATE OF status ON members
|
|
53
|
+
WHEN (NEW.status = 'suspended' AND NOT EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.ended_at IS NULL))
|
|
54
|
+
OR (NEW.status = 'active' AND EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.ended_at IS NULL))
|
|
55
|
+
OR (OLD.status = 'revoked' AND NEW.status <> 'revoked')
|
|
56
|
+
BEGIN SELECT RAISE(ABORT, 'status-follows-suspensions'); END;
|
|
57
|
+
-- R2: the status lifecycle. Never back to pending; pending -> active only with a confirmed enroll.
|
|
58
|
+
CREATE TRIGGER members_status_lifecycle BEFORE UPDATE OF status ON members
|
|
59
|
+
WHEN NEW.status <> OLD.status AND (
|
|
60
|
+
NEW.status = 'pending'
|
|
61
|
+
OR (OLD.status = 'pending' AND NEW.status NOT IN ('active','revoked'))
|
|
62
|
+
OR (OLD.status = 'pending' AND NEW.status = 'active' AND NOT EXISTS (
|
|
63
|
+
SELECT 1 FROM enrollments e WHERE e.member_id = NEW.member_id AND e.kind = 'enroll' AND e.confirmed_by IS NOT NULL)))
|
|
64
|
+
BEGIN SELECT RAISE(ABORT, 'status-lifecycle'); END;
|
|
65
|
+
-- members.role is written only by roles_project
|
|
66
|
+
CREATE TRIGGER members_role_is_projection BEFORE UPDATE OF role ON members
|
|
67
|
+
WHEN NEW.role IS NOT (SELECT r.role FROM roles r WHERE r.member_id = NEW.member_id ORDER BY r.seq DESC LIMIT 1)
|
|
68
|
+
BEGIN SELECT RAISE(ABORT, 'role-is-a-projection'); END;
|
|
69
|
+
|
|
70
|
+
-- One key ever (RD1): a key row is never deleted, its binding never changes, revoked is terminal.
|
|
71
|
+
CREATE TABLE keys(
|
|
72
|
+
public_key TEXT PRIMARY KEY CHECK (length(public_key) = 64),
|
|
73
|
+
agent TEXT NOT NULL UNIQUE,
|
|
74
|
+
principal_kind TEXT NOT NULL CHECK (principal_kind IN ('member','fleet')),
|
|
75
|
+
principal_id TEXT NOT NULL,
|
|
76
|
+
status TEXT NOT NULL CHECK (status IN ('pending','active','revoked')),
|
|
77
|
+
added_at INTEGER NOT NULL,
|
|
78
|
+
revoked_at INTEGER,
|
|
79
|
+
CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
|
|
80
|
+
) STRICT;
|
|
81
|
+
CREATE TRIGGER keys_no_delete BEFORE DELETE ON keys
|
|
82
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
83
|
+
CREATE TRIGGER keys_binding_immutable BEFORE UPDATE ON keys
|
|
84
|
+
WHEN NEW.public_key IS NOT OLD.public_key OR NEW.agent IS NOT OLD.agent
|
|
85
|
+
OR NEW.principal_kind IS NOT OLD.principal_kind OR NEW.principal_id IS NOT OLD.principal_id
|
|
86
|
+
OR NEW.added_at IS NOT OLD.added_at OR OLD.status = 'revoked'
|
|
87
|
+
BEGIN SELECT RAISE(ABORT, 'key-immutable'); END;
|
|
88
|
+
|
|
89
|
+
-- Owner-visible record outbox (9.5): immutable except a single confirmation.
|
|
90
|
+
CREATE TABLE owner_record_outbox(
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
subject TEXT NOT NULL, -- the decision or enrollment id
|
|
93
|
+
line TEXT NOT NULL,
|
|
94
|
+
line_sha256 TEXT NOT NULL CHECK (length(line_sha256) = 64),
|
|
95
|
+
created_at INTEGER NOT NULL,
|
|
96
|
+
confirmed_at INTEGER,
|
|
97
|
+
confirmed_by TEXT REFERENCES fleet_principals(principal_id),
|
|
98
|
+
CHECK ((confirmed_at IS NULL) = (confirmed_by IS NULL))
|
|
99
|
+
) STRICT;
|
|
100
|
+
CREATE TRIGGER outbox_no_delete BEFORE DELETE ON owner_record_outbox
|
|
101
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
102
|
+
CREATE TRIGGER outbox_confirm_once BEFORE UPDATE ON owner_record_outbox
|
|
103
|
+
WHEN NEW.id IS NOT OLD.id OR NEW.subject IS NOT OLD.subject OR NEW.line IS NOT OLD.line
|
|
104
|
+
OR NEW.line_sha256 IS NOT OLD.line_sha256 OR NEW.created_at IS NOT OLD.created_at
|
|
105
|
+
OR OLD.confirmed_at IS NOT NULL
|
|
106
|
+
BEGIN SELECT RAISE(ABORT, 'outbox-immutable'); END;
|
|
107
|
+
|
|
108
|
+
CREATE TABLE invites(
|
|
109
|
+
invite_id TEXT PRIMARY KEY,
|
|
110
|
+
kind TEXT NOT NULL CHECK (kind IN ('enroll','rekey','rebind')),
|
|
111
|
+
member_id TEXT REFERENCES members(member_id),
|
|
112
|
+
created_by TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
113
|
+
decision_id TEXT NOT NULL UNIQUE REFERENCES decisions(id), -- R-B: the invite.create decision that made it
|
|
114
|
+
secret_sha256 TEXT NOT NULL CHECK (length(secret_sha256) = 64),
|
|
115
|
+
created_at INTEGER NOT NULL,
|
|
116
|
+
expires_at INTEGER NOT NULL,
|
|
117
|
+
voided_at INTEGER,
|
|
118
|
+
CHECK ((kind = 'enroll') = (member_id IS NULL)),
|
|
119
|
+
CHECK (expires_at = created_at + 86400000)
|
|
120
|
+
) STRICT;
|
|
121
|
+
CREATE TRIGGER invites_no_delete BEFORE DELETE ON invites
|
|
122
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
123
|
+
CREATE TRIGGER invites_immutable BEFORE UPDATE ON invites
|
|
124
|
+
WHEN NEW.invite_id IS NOT OLD.invite_id OR NEW.kind IS NOT OLD.kind OR NEW.member_id IS NOT OLD.member_id
|
|
125
|
+
OR NEW.created_by IS NOT OLD.created_by OR NEW.secret_sha256 IS NOT OLD.secret_sha256
|
|
126
|
+
OR NEW.created_at IS NOT OLD.created_at OR NEW.expires_at IS NOT OLD.expires_at
|
|
127
|
+
OR NEW.decision_id IS NOT OLD.decision_id OR OLD.voided_at IS NOT NULL
|
|
128
|
+
BEGIN SELECT RAISE(ABORT, 'invite-immutable'); END;
|
|
129
|
+
-- R-B: an invite is made only by its own effective invite.create decision.
|
|
130
|
+
CREATE TRIGGER invites_need_decision BEFORE INSERT ON invites
|
|
131
|
+
WHEN NOT EXISTS (SELECT 1 FROM decisions d WHERE d.id = NEW.decision_id AND d.class = 'D0'
|
|
132
|
+
AND d.action = 'invite.create' AND d.state = 'effective')
|
|
133
|
+
BEGIN SELECT RAISE(ABORT, 'invite-needs-decision'); END;
|
|
134
|
+
-- R-B: at most one live re-bind attempt per member. An attempt is live while its suspension is open
|
|
135
|
+
-- and it has no rebind_ends row.
|
|
136
|
+
CREATE TRIGGER rebind_one_live_attempt BEFORE INSERT ON invites
|
|
137
|
+
WHEN NEW.kind = 'rebind' AND EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id
|
|
138
|
+
AND s.reason = 'rebind' AND s.ended_at IS NULL
|
|
139
|
+
AND NOT EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = s.invite_id))
|
|
140
|
+
BEGIN SELECT RAISE(ABORT, 'rebind-attempt-live'); END;
|
|
141
|
+
-- RC-2 (security review, ruled in D-1): a re-bind invite is only for an enrolled member that
|
|
142
|
+
-- is active or suspended; a revoked member's recovery is a fresh enrollment.
|
|
143
|
+
CREATE TRIGGER rebind_invite_member_enrolled BEFORE INSERT ON invites
|
|
144
|
+
WHEN NEW.kind = 'rebind' AND (SELECT status FROM members WHERE member_id = NEW.member_id) NOT IN ('active','suspended')
|
|
145
|
+
BEGIN SELECT RAISE(ABORT, 'rebind-member-not-enrolled'); END;
|
|
146
|
+
|
|
147
|
+
-- The spend ledger for all three enrollment flows. One row per invite, ever (M1).
|
|
148
|
+
CREATE TABLE enrollments(
|
|
149
|
+
id TEXT PRIMARY KEY,
|
|
150
|
+
invite_id TEXT NOT NULL UNIQUE REFERENCES invites(invite_id),
|
|
151
|
+
kind TEXT NOT NULL CHECK (kind IN ('enroll','rekey','rebind')),
|
|
152
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
153
|
+
public_key TEXT NOT NULL REFERENCES keys(public_key),
|
|
154
|
+
spent_at INTEGER NOT NULL,
|
|
155
|
+
confirmed_by TEXT REFERENCES fleet_principals(principal_id), -- enroll, rekey (D0); rebind confirms by a D1R decision
|
|
156
|
+
confirmed_at INTEGER,
|
|
157
|
+
owner_line_ref TEXT REFERENCES owner_record_outbox(id),
|
|
158
|
+
CHECK ((confirmed_by IS NULL) = (confirmed_at IS NULL)),
|
|
159
|
+
CHECK (confirmed_by IS NULL OR owner_line_ref IS NOT NULL),
|
|
160
|
+
CHECK (kind <> 'rebind' OR confirmed_by IS NULL)
|
|
161
|
+
) STRICT;
|
|
162
|
+
CREATE TRIGGER enroll_no_delete BEFORE DELETE ON enrollments
|
|
163
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
164
|
+
CREATE TRIGGER enroll_insert_unconfirmed BEFORE INSERT ON enrollments
|
|
165
|
+
WHEN NEW.confirmed_by IS NOT NULL OR NEW.confirmed_at IS NOT NULL OR NEW.owner_line_ref IS NOT NULL
|
|
166
|
+
BEGIN SELECT RAISE(ABORT, 'enrollment-starts-unconfirmed'); END;
|
|
167
|
+
-- B2 and M1: the flow matches its invite; the invite is live at the recorded spend time
|
|
168
|
+
CREATE TRIGGER enroll_matches_invite BEFORE INSERT ON enrollments
|
|
169
|
+
WHEN NOT EXISTS (SELECT 1 FROM invites i WHERE i.invite_id = NEW.invite_id AND i.kind = NEW.kind
|
|
170
|
+
AND (i.kind = 'enroll' OR i.member_id = NEW.member_id)
|
|
171
|
+
AND i.voided_at IS NULL AND NEW.spent_at >= i.created_at AND NEW.spent_at < i.expires_at)
|
|
172
|
+
BEGIN SELECT RAISE(ABORT, 'invite-mismatch-or-dead'); END;
|
|
173
|
+
-- RD2: no re-key while any suspension is open
|
|
174
|
+
CREATE TRIGGER rekey_not_suspended BEFORE INSERT ON enrollments
|
|
175
|
+
WHEN NEW.kind = 'rekey' AND ((SELECT status FROM members WHERE member_id = NEW.member_id) <> 'active'
|
|
176
|
+
OR EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.ended_at IS NULL))
|
|
177
|
+
BEGIN SELECT RAISE(ABORT, 'member-suspended'); END;
|
|
178
|
+
-- RD2: a re-bind proceeds only under its own open re-bind suspension, never for a revoked member
|
|
179
|
+
CREATE TRIGGER rebind_needs_own_suspension BEFORE INSERT ON enrollments
|
|
180
|
+
WHEN NEW.kind = 'rebind' AND ((SELECT status FROM members WHERE member_id = NEW.member_id) = 'revoked'
|
|
181
|
+
OR NOT EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.reason = 'rebind'
|
|
182
|
+
AND s.invite_id = NEW.invite_id AND s.ended_at IS NULL))
|
|
183
|
+
BEGIN SELECT RAISE(ABORT, 'rebind-needs-own-suspension'); END;
|
|
184
|
+
-- Immutable except a single confirmation
|
|
185
|
+
CREATE TRIGGER enroll_confirm_once BEFORE UPDATE ON enrollments
|
|
186
|
+
WHEN NEW.id IS NOT OLD.id OR NEW.invite_id IS NOT OLD.invite_id OR NEW.kind IS NOT OLD.kind
|
|
187
|
+
OR NEW.member_id IS NOT OLD.member_id OR NEW.public_key IS NOT OLD.public_key OR NEW.spent_at IS NOT OLD.spent_at
|
|
188
|
+
OR OLD.confirmed_by IS NOT NULL
|
|
189
|
+
BEGIN SELECT RAISE(ABORT, 'enrollment-immutable'); END;
|
|
190
|
+
-- P1: the confirming seat is not the invite creator, and is an AC seat
|
|
191
|
+
CREATE TRIGGER enroll_confirm_not_creator BEFORE UPDATE ON enrollments
|
|
192
|
+
WHEN OLD.confirmed_by IS NULL AND NEW.confirmed_by IS NOT NULL
|
|
193
|
+
AND NEW.confirmed_by = (SELECT created_by FROM invites WHERE invite_id = NEW.invite_id)
|
|
194
|
+
BEGIN SELECT RAISE(ABORT, 'confirmer-is-invite-creator'); END;
|
|
195
|
+
CREATE TRIGGER enroll_confirm_by_seat BEFORE UPDATE ON enrollments
|
|
196
|
+
WHEN OLD.confirmed_by IS NULL AND NEW.confirmed_by IS NOT NULL
|
|
197
|
+
AND (SELECT kind FROM fleet_principals WHERE principal_id = NEW.confirmed_by) IS NOT 'ac-seat'
|
|
198
|
+
BEGIN SELECT RAISE(ABORT, 'confirmer-not-ac-seat'); END;
|
|
199
|
+
-- M2: confirmation cites a confirmed owner-visible line about this enrollment, invite not voided,
|
|
200
|
+
-- enroll member still pending, rekey member still active and unsuspended
|
|
201
|
+
CREATE TRIGGER enroll_confirm_preconditions BEFORE UPDATE ON enrollments
|
|
202
|
+
WHEN OLD.confirmed_by IS NULL AND NEW.confirmed_by IS NOT NULL AND (
|
|
203
|
+
NOT EXISTS (SELECT 1 FROM owner_record_outbox o WHERE o.id = NEW.owner_line_ref AND o.subject = NEW.id
|
|
204
|
+
AND o.confirmed_at IS NOT NULL AND o.confirmed_at <= NEW.confirmed_at)
|
|
205
|
+
OR (SELECT voided_at FROM invites WHERE invite_id = NEW.invite_id) IS NOT NULL
|
|
206
|
+
OR (NEW.kind = 'enroll' AND (SELECT status FROM members WHERE member_id = NEW.member_id) <> 'pending')
|
|
207
|
+
OR (NEW.kind = 'rekey' AND ((SELECT status FROM members WHERE member_id = NEW.member_id) <> 'active'
|
|
208
|
+
OR EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.ended_at IS NULL))))
|
|
209
|
+
BEGIN SELECT RAISE(ABORT, 'confirm-precondition'); END;
|
|
210
|
+
|
|
211
|
+
CREATE TABLE decisions(
|
|
212
|
+
id TEXT PRIMARY KEY,
|
|
213
|
+
class TEXT NOT NULL CHECK (class IN ('D0','D1','D1R','D2','SUSPEND','OWNER')), -- D1R = re-bind; no D3
|
|
214
|
+
action TEXT NOT NULL,
|
|
215
|
+
subject TEXT NOT NULL, -- member_id, task_id, submission_id or enrollment id, by action
|
|
216
|
+
sponsor TEXT REFERENCES fleet_principals(principal_id),
|
|
217
|
+
lane TEXT, -- S1: the signed body's lane; set exactly for D0, D1 and D2
|
|
218
|
+
state TEXT NOT NULL CHECK (state IN ('pending','effective','vetoed','lapsed','withdrawn','reversed')), -- 'blocked' is derived from live verdicts, never stored
|
|
219
|
+
owner_line_ref TEXT REFERENCES owner_record_outbox(id),
|
|
220
|
+
owner_posted_at INTEGER,
|
|
221
|
+
effect_at INTEGER,
|
|
222
|
+
served_by TEXT NOT NULL,
|
|
223
|
+
created_at INTEGER NOT NULL,
|
|
224
|
+
body TEXT NOT NULL,
|
|
225
|
+
sig TEXT NOT NULL,
|
|
226
|
+
CHECK (class <> 'D2' OR sponsor IS NOT NULL),
|
|
227
|
+
CHECK ((class IN ('D0','D1','D2')) = (lane IS NOT NULL)),
|
|
228
|
+
CHECK ((owner_posted_at IS NULL) = (owner_line_ref IS NULL)),
|
|
229
|
+
CHECK (class = 'D2' OR effect_at IS NULL),
|
|
230
|
+
CHECK (class <> 'D2' OR (effect_at IS NULL) = (owner_posted_at IS NULL)),
|
|
231
|
+
CHECK (class <> 'D2' OR effect_at IS NULL OR effect_at = owner_posted_at + 86400000),
|
|
232
|
+
CHECK (class NOT IN ('D2','D1R') OR state NOT IN ('effective','reversed') OR owner_posted_at IS NOT NULL)
|
|
233
|
+
) STRICT;
|
|
234
|
+
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions
|
|
235
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
236
|
+
CREATE TRIGGER decisions_start_pending BEFORE INSERT ON decisions
|
|
237
|
+
WHEN NEW.state <> 'pending' OR NEW.owner_posted_at IS NOT NULL
|
|
238
|
+
BEGIN SELECT RAISE(ABORT, 'decision-starts-pending'); END;
|
|
239
|
+
CREATE TRIGGER decisions_immutable BEFORE UPDATE ON decisions
|
|
240
|
+
WHEN NEW.id IS NOT OLD.id OR NEW.class IS NOT OLD.class OR NEW.action IS NOT OLD.action
|
|
241
|
+
OR NEW.subject IS NOT OLD.subject OR NEW.sponsor IS NOT OLD.sponsor OR NEW.lane IS NOT OLD.lane OR NEW.served_by IS NOT OLD.served_by
|
|
242
|
+
OR NEW.created_at IS NOT OLD.created_at OR NEW.body IS NOT OLD.body OR NEW.sig IS NOT OLD.sig
|
|
243
|
+
OR (OLD.owner_posted_at IS NOT NULL AND (NEW.owner_posted_at IS NOT OLD.owner_posted_at
|
|
244
|
+
OR NEW.owner_line_ref IS NOT OLD.owner_line_ref OR NEW.effect_at IS NOT OLD.effect_at))
|
|
245
|
+
BEGIN SELECT RAISE(ABORT, 'decision-immutable'); END;
|
|
246
|
+
CREATE TRIGGER decisions_transition BEFORE UPDATE OF state ON decisions
|
|
247
|
+
WHEN NOT ((OLD.state = 'pending' AND NEW.state IN ('pending','effective','vetoed','lapsed','withdrawn'))
|
|
248
|
+
OR (OLD.state = 'effective' AND NEW.state IN ('effective','reversed'))
|
|
249
|
+
OR OLD.state = NEW.state)
|
|
250
|
+
BEGIN SELECT RAISE(ABORT, 'illegal-transition'); END;
|
|
251
|
+
-- C3: only an owner veto that cites this decision vetoes or reverses it
|
|
252
|
+
CREATE TRIGGER decisions_veto_needed BEFORE UPDATE OF state ON decisions
|
|
253
|
+
WHEN NEW.state IN ('vetoed','reversed') AND NEW.state IS NOT OLD.state
|
|
254
|
+
AND NOT EXISTS (SELECT 1 FROM decisions v WHERE v.class = 'OWNER' AND v.action = 'veto' AND v.subject = NEW.id)
|
|
255
|
+
BEGIN SELECT RAISE(ABORT, 'veto-needed'); END;
|
|
256
|
+
-- T-C5: the owner-visible time is the confirmed post of a line about this decision
|
|
257
|
+
CREATE TRIGGER decisions_post_confirmed BEFORE UPDATE ON decisions
|
|
258
|
+
WHEN OLD.owner_posted_at IS NULL AND NEW.owner_posted_at IS NOT NULL AND NOT EXISTS (
|
|
259
|
+
SELECT 1 FROM owner_record_outbox o WHERE o.id = NEW.owner_line_ref AND o.subject = NEW.id
|
|
260
|
+
AND o.confirmed_at = NEW.owner_posted_at)
|
|
261
|
+
BEGIN SELECT RAISE(ABORT, 'owner-post-unconfirmed'); END;
|
|
262
|
+
|
|
263
|
+
-- Authors are fixed before the first verdict and never change.
|
|
264
|
+
CREATE TABLE decision_authors(
|
|
265
|
+
decision_id TEXT NOT NULL REFERENCES decisions(id),
|
|
266
|
+
principal TEXT NOT NULL, -- a member_id or a fleet principal_id
|
|
267
|
+
PRIMARY KEY(decision_id, principal)
|
|
268
|
+
) STRICT;
|
|
269
|
+
CREATE TRIGGER authors_frozen BEFORE INSERT ON decision_authors
|
|
270
|
+
WHEN EXISTS (SELECT 1 FROM approvals a WHERE a.decision_id = NEW.decision_id)
|
|
271
|
+
OR (SELECT state FROM decisions WHERE id = NEW.decision_id) <> 'pending'
|
|
272
|
+
BEGIN SELECT RAISE(ABORT, 'authors-frozen'); END;
|
|
273
|
+
CREATE TRIGGER authors_no_update BEFORE UPDATE ON decision_authors
|
|
274
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
275
|
+
CREATE TRIGGER authors_no_delete BEFORE DELETE ON decision_authors
|
|
276
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
277
|
+
|
|
278
|
+
-- Verdicts are append-only. Each approver's latest seq is its current verdict.
|
|
279
|
+
CREATE TABLE approvals(
|
|
280
|
+
decision_id TEXT NOT NULL REFERENCES decisions(id),
|
|
281
|
+
approver TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
282
|
+
seq INTEGER NOT NULL CHECK (seq >= 1),
|
|
283
|
+
prev TEXT CHECK (prev IS NULL OR length(prev) = 64), -- sha256(JCS(this approver's previous verdict body)); NULL only at seq 1
|
|
284
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('approve','object','reject','withdraw')),
|
|
285
|
+
approver_kind TEXT NOT NULL,
|
|
286
|
+
approver_family TEXT,
|
|
287
|
+
served_by TEXT NOT NULL,
|
|
288
|
+
signed_at INTEGER NOT NULL,
|
|
289
|
+
body TEXT NOT NULL,
|
|
290
|
+
body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64), -- sha256(JCS(body)), computed by the relay
|
|
291
|
+
sig TEXT NOT NULL,
|
|
292
|
+
PRIMARY KEY(decision_id, approver, seq),
|
|
293
|
+
UNIQUE(decision_id, body_sha256) -- C1: a signed body is accepted at most once
|
|
294
|
+
) STRICT;
|
|
295
|
+
CREATE TRIGGER approvals_no_update BEFORE UPDATE ON approvals
|
|
296
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
297
|
+
CREATE TRIGGER approvals_no_delete BEFORE DELETE ON approvals
|
|
298
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
299
|
+
CREATE TRIGGER approvals_seq BEFORE INSERT ON approvals
|
|
300
|
+
WHEN NEW.seq <> 1 + coalesce((SELECT max(seq) FROM approvals a WHERE a.decision_id = NEW.decision_id AND a.approver = NEW.approver), 0)
|
|
301
|
+
BEGIN SELECT RAISE(ABORT, 'approval-seq'); END;
|
|
302
|
+
-- C1: each verdict links to the approver's previous one on this decision
|
|
303
|
+
CREATE TRIGGER approvals_prev_chain BEFORE INSERT ON approvals
|
|
304
|
+
WHEN NOT ((NEW.seq = 1 AND NEW.prev IS NULL)
|
|
305
|
+
OR (NEW.seq > 1 AND NEW.prev IS (SELECT a.body_sha256 FROM approvals a
|
|
306
|
+
WHERE a.decision_id = NEW.decision_id AND a.approver = NEW.approver AND a.seq = NEW.seq - 1)))
|
|
307
|
+
BEGIN SELECT RAISE(ABORT, 'approval-seq'); END;
|
|
308
|
+
-- C1/C2/S2: the decision body is only ever its creator's implicit seq-1 approve, for D0, D1 and D1R,
|
|
309
|
+
-- attributed to the creator named in the signed body
|
|
310
|
+
CREATE TRIGGER approvals_implicit BEFORE INSERT ON approvals
|
|
311
|
+
WHEN NEW.body = (SELECT d.body FROM decisions d WHERE d.id = NEW.decision_id)
|
|
312
|
+
AND (NEW.seq <> 1 OR NEW.verdict <> 'approve'
|
|
313
|
+
OR NEW.sig IS NOT (SELECT d.sig FROM decisions d WHERE d.id = NEW.decision_id)
|
|
314
|
+
OR NEW.approver IS NOT (SELECT json_extract(d.body, '$.by') FROM decisions d WHERE d.id = NEW.decision_id)
|
|
315
|
+
OR (SELECT d.class FROM decisions d WHERE d.id = NEW.decision_id) NOT IN ('D0','D1','D1R'))
|
|
316
|
+
BEGIN SELECT RAISE(ABORT, 'implicit-approval'); END;
|
|
317
|
+
-- C5: only AC seats file verdicts; a measurer, verifier, security, scope or owner principal never votes
|
|
318
|
+
CREATE TRIGGER approvals_ac_seat_only BEFORE INSERT ON approvals
|
|
319
|
+
WHEN NEW.approver_kind <> 'ac-seat'
|
|
320
|
+
BEGIN SELECT RAISE(ABORT, 'approver-not-ac-seat'); END;
|
|
321
|
+
-- N4: object is a D2 verdict; on D0, D1 and D1R a seat blocks with reject
|
|
322
|
+
CREATE TRIGGER approvals_object_d2_only BEFORE INSERT ON approvals
|
|
323
|
+
WHEN NEW.verdict = 'object' AND (SELECT d.class FROM decisions d WHERE d.id = NEW.decision_id) <> 'D2'
|
|
324
|
+
BEGIN SELECT RAISE(ABORT, 'object-d2-only'); END;
|
|
325
|
+
CREATE TRIGGER approvals_roster BEFORE INSERT ON approvals
|
|
326
|
+
WHEN NOT EXISTS (SELECT 1 FROM fleet_principals f WHERE f.principal_id = NEW.approver AND f.kind = NEW.approver_kind
|
|
327
|
+
AND f.runtime_family IS NEW.approver_family AND f.status = 'active')
|
|
328
|
+
BEGIN SELECT RAISE(ABORT, 'approver-not-in-roster'); END;
|
|
329
|
+
CREATE TRIGGER approvals_pending_only BEFORE INSERT ON approvals
|
|
330
|
+
WHEN (SELECT state FROM decisions WHERE id = NEW.decision_id) IS NOT 'pending'
|
|
331
|
+
BEGIN SELECT RAISE(ABORT, 'decision-closed'); END;
|
|
332
|
+
-- author != approver. Authors may still object or reject (that only blocks).
|
|
333
|
+
CREATE TRIGGER approvals_no_author BEFORE INSERT ON approvals
|
|
334
|
+
WHEN NEW.verdict = 'approve' AND EXISTS (SELECT 1 FROM decision_authors a
|
|
335
|
+
WHERE a.decision_id = NEW.decision_id AND a.principal = NEW.approver)
|
|
336
|
+
BEGIN SELECT RAISE(ABORT, 'author-cannot-approve'); END;
|
|
337
|
+
-- P2 and A1: the D2 sponsor never counts; its only verdict is withdraw, which ends the D2.
|
|
338
|
+
CREATE TRIGGER approvals_no_sponsor_d2 BEFORE INSERT ON approvals
|
|
339
|
+
WHEN NEW.verdict <> 'withdraw' AND EXISTS (SELECT 1 FROM decisions d
|
|
340
|
+
WHERE d.id = NEW.decision_id AND d.class = 'D2' AND d.sponsor = NEW.approver)
|
|
341
|
+
BEGIN SELECT RAISE(ABORT, 'sponsor-excluded'); END;
|
|
342
|
+
CREATE TRIGGER sponsor_withdraw_ends_d2 AFTER INSERT ON approvals
|
|
343
|
+
WHEN NEW.verdict = 'withdraw' AND EXISTS (SELECT 1 FROM decisions d
|
|
344
|
+
WHERE d.id = NEW.decision_id AND d.class = 'D2' AND d.sponsor = NEW.approver)
|
|
345
|
+
BEGIN UPDATE decisions SET state = 'withdrawn' WHERE id = NEW.decision_id; END;
|
|
346
|
+
-- P1 for re-bind: no approval from the invite creator
|
|
347
|
+
CREATE TRIGGER approvals_no_invite_creator_d1r BEFORE INSERT ON approvals
|
|
348
|
+
WHEN NEW.verdict = 'approve' AND EXISTS (SELECT 1 FROM decisions d
|
|
349
|
+
JOIN enrollments e ON e.id = d.subject JOIN invites i ON i.invite_id = e.invite_id
|
|
350
|
+
WHERE d.id = NEW.decision_id AND d.class = 'D1R' AND i.created_by = NEW.approver)
|
|
351
|
+
BEGIN SELECT RAISE(ABORT, 'confirmer-is-invite-creator'); END;
|
|
352
|
+
|
|
353
|
+
-- RD2: suspensions carry a reason; a re-bind confirmation ends only its own.
|
|
354
|
+
CREATE TABLE suspensions(
|
|
355
|
+
id TEXT PRIMARY KEY,
|
|
356
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
357
|
+
reason TEXT NOT NULL CHECK (reason IN ('decision','rebind')),
|
|
358
|
+
decision_id TEXT REFERENCES decisions(id),
|
|
359
|
+
invite_id TEXT REFERENCES invites(invite_id),
|
|
360
|
+
started_at INTEGER NOT NULL,
|
|
361
|
+
ended_at INTEGER,
|
|
362
|
+
ended_by_decision TEXT REFERENCES decisions(id),
|
|
363
|
+
CHECK ((reason = 'rebind') = (invite_id IS NOT NULL)),
|
|
364
|
+
CHECK ((reason = 'decision') = (decision_id IS NOT NULL)),
|
|
365
|
+
CHECK ((ended_at IS NULL) = (ended_by_decision IS NULL))
|
|
366
|
+
) STRICT;
|
|
367
|
+
CREATE TRIGGER suspensions_no_delete BEFORE DELETE ON suspensions
|
|
368
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
369
|
+
CREATE TRIGGER suspensions_end_once BEFORE UPDATE ON suspensions
|
|
370
|
+
WHEN NEW.id IS NOT OLD.id OR NEW.member_id IS NOT OLD.member_id OR NEW.reason IS NOT OLD.reason
|
|
371
|
+
OR NEW.decision_id IS NOT OLD.decision_id OR NEW.invite_id IS NOT OLD.invite_id
|
|
372
|
+
OR NEW.started_at IS NOT OLD.started_at OR OLD.ended_at IS NOT NULL
|
|
373
|
+
BEGIN SELECT RAISE(ABORT, 'suspension-immutable'); END;
|
|
374
|
+
-- R-B: a re-bind attempt that can no longer complete is recorded dead once, with its cause.
|
|
375
|
+
-- Recorded by the reducer (sweep, void, cancel, owner reversal, D2 reinstate); checked here against the rows.
|
|
376
|
+
CREATE TABLE rebind_ends(
|
|
377
|
+
invite_id TEXT PRIMARY KEY REFERENCES invites(invite_id),
|
|
378
|
+
cause TEXT NOT NULL CHECK (cause IN ('invite-expired','invite-voided','enrollment-expired','cancelled','reversed','reinstated')),
|
|
379
|
+
decision_id TEXT REFERENCES decisions(id),
|
|
380
|
+
at INTEGER NOT NULL,
|
|
381
|
+
CHECK ((cause IN ('cancelled','reversed','reinstated')) = (decision_id IS NOT NULL))
|
|
382
|
+
) STRICT;
|
|
383
|
+
CREATE TRIGGER rebind_ends_no_delete BEFORE DELETE ON rebind_ends
|
|
384
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
385
|
+
CREATE TRIGGER rebind_ends_no_update BEFORE UPDATE ON rebind_ends
|
|
386
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
387
|
+
CREATE TRIGGER rebind_ends_only_dead BEFORE INSERT ON rebind_ends
|
|
388
|
+
WHEN (SELECT kind FROM invites WHERE invite_id = NEW.invite_id) IS NOT 'rebind'
|
|
389
|
+
OR EXISTS (SELECT 1 FROM enrollments e JOIN decisions d ON d.subject = e.id
|
|
390
|
+
WHERE e.invite_id = NEW.invite_id AND d.class = 'D1R' AND d.state IN ('effective','reversed'))
|
|
391
|
+
OR NOT (
|
|
392
|
+
(NEW.cause = 'invite-expired' AND NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.invite_id = NEW.invite_id)
|
|
393
|
+
AND NEW.at >= (SELECT expires_at FROM invites WHERE invite_id = NEW.invite_id))
|
|
394
|
+
OR (NEW.cause = 'invite-voided' AND NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.invite_id = NEW.invite_id)
|
|
395
|
+
AND (SELECT voided_at FROM invites WHERE invite_id = NEW.invite_id) IS NOT NULL)
|
|
396
|
+
OR (NEW.cause = 'enrollment-expired' AND EXISTS (SELECT 1 FROM enrollments e JOIN keys k USING(public_key)
|
|
397
|
+
WHERE e.invite_id = NEW.invite_id AND k.status = 'revoked' AND NEW.at >= e.spent_at + 259200000))
|
|
398
|
+
OR (NEW.cause IN ('cancelled','reversed','reinstated')
|
|
399
|
+
AND ((NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.invite_id = NEW.invite_id)
|
|
400
|
+
AND (SELECT voided_at FROM invites WHERE invite_id = NEW.invite_id) IS NOT NULL)
|
|
401
|
+
OR EXISTS (SELECT 1 FROM enrollments e JOIN keys k USING(public_key)
|
|
402
|
+
WHERE e.invite_id = NEW.invite_id AND k.status = 'revoked'))
|
|
403
|
+
AND ((NEW.cause = 'cancelled' AND EXISTS (SELECT 1 FROM decisions d WHERE d.id = NEW.decision_id
|
|
404
|
+
AND d.class = 'D0' AND d.action = 'rebind.cancel' AND d.subject = NEW.invite_id AND d.state = 'effective'))
|
|
405
|
+
OR (NEW.cause = 'reversed' AND EXISTS (SELECT 1 FROM decisions v JOIN invites i ON i.decision_id = v.subject
|
|
406
|
+
WHERE v.id = NEW.decision_id AND v.class = 'OWNER' AND v.action = 'veto' AND i.invite_id = NEW.invite_id))
|
|
407
|
+
OR (NEW.cause = 'reinstated' AND EXISTS (SELECT 1 FROM decisions d WHERE d.id = NEW.decision_id
|
|
408
|
+
AND d.class = 'D2' AND d.action = 'reinstate' AND d.state = 'effective'
|
|
409
|
+
AND d.subject = (SELECT member_id FROM invites WHERE invite_id = NEW.invite_id))))))
|
|
410
|
+
BEGIN SELECT RAISE(ABORT, 'rebind-end-invalid'); END;
|
|
411
|
+
-- R-B: a D1R about a dead attempt never takes effect (the reducer lapses it instead).
|
|
412
|
+
CREATE TRIGGER rebind_confirm_needs_live_attempt BEFORE UPDATE OF state ON decisions
|
|
413
|
+
WHEN NEW.state = 'effective' AND OLD.state = 'pending' AND NEW.class = 'D1R'
|
|
414
|
+
AND EXISTS (SELECT 1 FROM enrollments e JOIN rebind_ends r ON r.invite_id = e.invite_id WHERE e.id = NEW.subject)
|
|
415
|
+
BEGIN SELECT RAISE(ABORT, 'rebind-attempt-dead'); END;
|
|
416
|
+
-- A re-bind suspension ends by an effective D1R about its own invite's enrollment. Once its attempt is
|
|
417
|
+
-- recorded dead (rebind_ends), it also ends by the member's D2 reinstate, by a newer re-bind invite for the
|
|
418
|
+
-- member (supersession), or by the owner veto that reversed its invite.create. A decision suspension ends
|
|
419
|
+
-- only by an effective D2 reinstatement of that member, or by the owner veto that reversed its decision.
|
|
420
|
+
CREATE TRIGGER suspensions_end_by_matching_decision BEFORE UPDATE OF ended_at ON suspensions
|
|
421
|
+
WHEN NEW.ended_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM decisions d WHERE d.id = NEW.ended_by_decision AND d.state = 'effective'
|
|
422
|
+
AND ((OLD.reason = 'rebind' AND d.class = 'D1R' AND d.subject = (SELECT e.id FROM enrollments e WHERE e.invite_id = OLD.invite_id))
|
|
423
|
+
OR (OLD.reason = 'rebind' AND EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = OLD.invite_id)
|
|
424
|
+
AND ((d.class = 'D2' AND d.action = 'reinstate' AND d.subject = OLD.member_id)
|
|
425
|
+
OR (d.class = 'D0' AND d.action = 'invite.create' AND EXISTS (SELECT 1 FROM invites i2
|
|
426
|
+
WHERE i2.decision_id = d.id AND i2.kind = 'rebind' AND i2.member_id = OLD.member_id AND i2.invite_id <> OLD.invite_id))
|
|
427
|
+
OR (d.class = 'OWNER' AND d.action = 'veto' AND d.subject = (SELECT i.decision_id FROM invites i WHERE i.invite_id = OLD.invite_id))))
|
|
428
|
+
OR (OLD.reason = 'decision' AND d.class = 'D2' AND d.action = 'reinstate' AND d.subject = OLD.member_id)
|
|
429
|
+
OR (OLD.reason = 'decision' AND d.class = 'OWNER' AND d.action = 'veto' AND d.subject = OLD.decision_id)))
|
|
430
|
+
BEGIN SELECT RAISE(ABORT, 'suspension-end-needs-matching-decision'); END;
|
|
431
|
+
-- P3: opening a suspension suspends the member and releases its claims in the same statement
|
|
432
|
+
CREATE TRIGGER suspension_opens AFTER INSERT ON suspensions
|
|
433
|
+
BEGIN
|
|
434
|
+
UPDATE members SET status = 'suspended', updated_at = NEW.started_at
|
|
435
|
+
WHERE member_id = NEW.member_id AND status IN ('active','suspended');
|
|
436
|
+
UPDATE claims SET state = 'released', released_reason = 'suspension', released_at = NEW.started_at
|
|
437
|
+
WHERE member_id = NEW.member_id AND state = 'live';
|
|
438
|
+
UPDATE submissions SET state = 'held' WHERE member_id = NEW.member_id AND state = 'in-review';
|
|
439
|
+
END;
|
|
440
|
+
CREATE TRIGGER suspension_closes AFTER UPDATE OF ended_at ON suspensions
|
|
441
|
+
WHEN OLD.ended_at IS NULL AND NEW.ended_at IS NOT NULL
|
|
442
|
+
BEGIN
|
|
443
|
+
UPDATE members SET status = 'active', updated_at = NEW.ended_at
|
|
444
|
+
WHERE member_id = NEW.member_id AND status = 'suspended'
|
|
445
|
+
AND NOT EXISTS (SELECT 1 FROM suspensions s WHERE s.member_id = NEW.member_id AND s.ended_at IS NULL);
|
|
446
|
+
END;
|
|
447
|
+
-- Revocation releases claims the same way.
|
|
448
|
+
CREATE TRIGGER revocation_releases AFTER UPDATE OF status ON members
|
|
449
|
+
WHEN NEW.status = 'revoked' AND OLD.status <> 'revoked'
|
|
450
|
+
BEGIN
|
|
451
|
+
UPDATE claims SET state = 'released', released_reason = 'revocation', released_at = NEW.updated_at
|
|
452
|
+
WHERE member_id = NEW.member_id AND state = 'live';
|
|
453
|
+
UPDATE submissions SET state = 'held' WHERE member_id = NEW.member_id AND state = 'in-review';
|
|
454
|
+
END;
|
|
455
|
+
|
|
456
|
+
-- Columns the invariants need; the board module adds the rest (11.1-11.3).
|
|
457
|
+
CREATE TABLE claims(
|
|
458
|
+
claim_id TEXT PRIMARY KEY,
|
|
459
|
+
task_id TEXT NOT NULL,
|
|
460
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
461
|
+
epoch INTEGER NOT NULL,
|
|
462
|
+
state TEXT NOT NULL CHECK (state IN ('live','released','expired','submitted')),
|
|
463
|
+
lease_until INTEGER NOT NULL,
|
|
464
|
+
released_reason TEXT,
|
|
465
|
+
released_at INTEGER
|
|
466
|
+
) STRICT;
|
|
467
|
+
CREATE TRIGGER claims_member_active BEFORE INSERT ON claims
|
|
468
|
+
WHEN NEW.state <> 'live' OR (SELECT status FROM members WHERE member_id = NEW.member_id) IS NOT 'active'
|
|
469
|
+
BEGIN SELECT RAISE(ABORT, 'member-not-active'); END;
|
|
470
|
+
-- R1: a claim's binding never changes; the lease moves only while the claim is live.
|
|
471
|
+
-- A suspended or revoked member has no live claim (suspension_opens, revocation_releases).
|
|
472
|
+
CREATE TRIGGER claims_binding_immutable BEFORE UPDATE ON claims
|
|
473
|
+
WHEN NEW.claim_id IS NOT OLD.claim_id OR NEW.task_id IS NOT OLD.task_id
|
|
474
|
+
OR NEW.member_id IS NOT OLD.member_id OR NEW.epoch IS NOT OLD.epoch
|
|
475
|
+
OR (NEW.lease_until IS NOT OLD.lease_until AND OLD.state <> 'live')
|
|
476
|
+
BEGIN SELECT RAISE(ABORT, 'claim-immutable'); END;
|
|
477
|
+
CREATE TRIGGER claims_no_revival BEFORE UPDATE OF state ON claims
|
|
478
|
+
WHEN OLD.state <> 'live' AND NEW.state <> OLD.state
|
|
479
|
+
BEGIN SELECT RAISE(ABORT, 'claim-closed'); END;
|
|
480
|
+
CREATE TRIGGER claims_no_delete BEFORE DELETE ON claims
|
|
481
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
482
|
+
|
|
483
|
+
CREATE TABLE submissions(
|
|
484
|
+
submission_id TEXT PRIMARY KEY,
|
|
485
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
486
|
+
state TEXT NOT NULL CHECK (state IN ('in-review','held','accepted','rejected','changes','reversed'))
|
|
487
|
+
) STRICT;
|
|
488
|
+
CREATE TRIGGER submissions_binding_immutable BEFORE UPDATE ON submissions
|
|
489
|
+
WHEN NEW.submission_id IS NOT OLD.submission_id OR NEW.member_id IS NOT OLD.member_id
|
|
490
|
+
BEGIN SELECT RAISE(ABORT, 'submission-immutable'); END;
|
|
491
|
+
CREATE TRIGGER submissions_no_delete BEFORE DELETE ON submissions
|
|
492
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
493
|
+
-- C3: 'reversed' only from 'accepted', only once its accept decision is reversed, and terminal
|
|
494
|
+
CREATE TRIGGER submissions_reversed BEFORE UPDATE OF state ON submissions
|
|
495
|
+
WHEN (OLD.state = 'reversed' AND NEW.state <> 'reversed')
|
|
496
|
+
OR (NEW.state = 'reversed' AND OLD.state <> 'reversed' AND (OLD.state <> 'accepted'
|
|
497
|
+
OR NOT EXISTS (SELECT 1 FROM decisions d WHERE d.action = 'accept' AND d.subject = NEW.submission_id AND d.state = 'reversed')))
|
|
498
|
+
BEGIN SELECT RAISE(ABORT, 'submission-reversal'); END;
|
|
499
|
+
CREATE TRIGGER submissions_held BEFORE UPDATE OF state ON submissions
|
|
500
|
+
WHEN OLD.state = 'held' AND NOT (NEW.state = 'held'
|
|
501
|
+
OR (NEW.state = 'in-review' AND (SELECT status FROM members WHERE member_id = NEW.member_id) = 'active'))
|
|
502
|
+
BEGIN SELECT RAISE(ABORT, 'submission-held'); END;
|
|
503
|
+
|
|
504
|
+
-- Roles are append-only; each row needs an effective decision of the matching class.
|
|
505
|
+
CREATE TABLE roles(
|
|
506
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
507
|
+
seq INTEGER NOT NULL CHECK (seq >= 1),
|
|
508
|
+
role TEXT NOT NULL CHECK (role IN ('C0','C1','C2','C3')),
|
|
509
|
+
decision_id TEXT NOT NULL UNIQUE REFERENCES decisions(id),
|
|
510
|
+
at INTEGER NOT NULL,
|
|
511
|
+
PRIMARY KEY(member_id, seq)
|
|
512
|
+
) STRICT;
|
|
513
|
+
CREATE TRIGGER roles_no_update BEFORE UPDATE ON roles
|
|
514
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
515
|
+
CREATE TRIGGER roles_no_delete BEFORE DELETE ON roles
|
|
516
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
517
|
+
CREATE TRIGGER roles_seq BEFORE INSERT ON roles
|
|
518
|
+
WHEN NEW.seq <> 1 + coalesce((SELECT max(seq) FROM roles r WHERE r.member_id = NEW.member_id), 0)
|
|
519
|
+
BEGIN SELECT RAISE(ABORT, 'role-seq'); END;
|
|
520
|
+
-- A role row is a grant by an effective role decision, or (C3) a re-projection that cites the
|
|
521
|
+
-- owner veto reversing a role decision and equals the member's latest still-effective grant.
|
|
522
|
+
CREATE TRIGGER roles_need_decision BEFORE INSERT ON roles
|
|
523
|
+
WHEN NOT (EXISTS (SELECT 1 FROM decisions d WHERE d.id = NEW.decision_id AND d.state = 'effective'
|
|
524
|
+
AND d.subject = NEW.member_id
|
|
525
|
+
AND ((NEW.role = 'C0' AND d.class = 'D0' AND d.action = 'enroll.confirm')
|
|
526
|
+
OR (NEW.role = 'C1' AND d.class = 'D0' AND d.action = 'role.c1')
|
|
527
|
+
OR (d.class = 'D2' AND d.action = 'role.change')))
|
|
528
|
+
OR EXISTS (SELECT 1 FROM decisions v JOIN decisions t ON t.id = v.subject
|
|
529
|
+
WHERE v.id = NEW.decision_id AND v.class = 'OWNER' AND v.action = 'veto' AND v.state = 'effective'
|
|
530
|
+
AND t.state = 'reversed' AND t.subject = NEW.member_id AND t.action IN ('role.c1','role.change')
|
|
531
|
+
AND NEW.role IS (SELECT r.role FROM roles r JOIN decisions x ON x.id = r.decision_id
|
|
532
|
+
WHERE r.member_id = NEW.member_id AND x.state = 'effective'
|
|
533
|
+
AND x.action IN ('enroll.confirm','role.c1','role.change')
|
|
534
|
+
ORDER BY r.seq DESC LIMIT 1)))
|
|
535
|
+
BEGIN SELECT RAISE(ABORT, 'role-needs-effective-decision'); END;
|
|
536
|
+
-- S5 (D-2): a revoked member's role is frozen at revocation; no role row is ever added after it.
|
|
537
|
+
CREATE TRIGGER roles_frozen_when_revoked BEFORE INSERT ON roles
|
|
538
|
+
WHEN (SELECT m.status FROM members m WHERE m.member_id = NEW.member_id) IS 'revoked'
|
|
539
|
+
BEGIN SELECT RAISE(ABORT, 'role-frozen-revoked'); END;
|
|
540
|
+
CREATE TRIGGER roles_project AFTER INSERT ON roles
|
|
541
|
+
BEGIN UPDATE members SET role = NEW.role WHERE member_id = NEW.member_id; END;
|
|
542
|
+
|
|
543
|
+
-- C5: the owner-signed policy's model -> family map, loaded with the policy; no API changes it.
|
|
544
|
+
-- A model maps only if exactly one pattern of the current policy version matches (SQLite GLOB).
|
|
545
|
+
CREATE TABLE policy_family_map(
|
|
546
|
+
policy_version INTEGER NOT NULL CHECK (policy_version >= 1),
|
|
547
|
+
pattern TEXT NOT NULL CHECK (length(pattern) BETWEEN 1 AND 64 AND pattern GLOB '[A-Za-z]*'
|
|
548
|
+
AND pattern NOT GLOB '*[^A-Za-z0-9.*?_-]*'), -- D-1: the WHOLE pattern is D9.3's alphabet
|
|
549
|
+
family TEXT NOT NULL CHECK (length(family) BETWEEN 1 AND 32 AND family GLOB '[a-z]*'
|
|
550
|
+
AND family NOT GLOB '*[^a-z0-9-]*'), -- D-1: the WHOLE family is [a-z][a-z0-9-]{0,31}
|
|
551
|
+
PRIMARY KEY(policy_version, pattern)
|
|
552
|
+
) STRICT;
|
|
553
|
+
CREATE TRIGGER policy_family_map_no_update BEFORE UPDATE ON policy_family_map
|
|
554
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
555
|
+
CREATE TRIGGER policy_family_map_no_delete BEFORE DELETE ON policy_family_map
|
|
556
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
557
|
+
|
|
558
|
+
-- C5: a measurer's signed evidence of which model really served a verdict's turn.
|
|
559
|
+
-- Rules 1-4 are structural here; rule 5 (equal to the seat's asserted family) is the reducer's.
|
|
560
|
+
CREATE TABLE served_by_attestations(
|
|
561
|
+
id TEXT PRIMARY KEY,
|
|
562
|
+
decision_id TEXT NOT NULL,
|
|
563
|
+
target_by TEXT NOT NULL,
|
|
564
|
+
target_seq INTEGER NOT NULL,
|
|
565
|
+
measurer TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
566
|
+
session_id TEXT NOT NULL,
|
|
567
|
+
record_uuid TEXT NOT NULL,
|
|
568
|
+
response_model TEXT NOT NULL,
|
|
569
|
+
turn_models TEXT NOT NULL CHECK (json_valid(turn_models) AND json_type(turn_models) = 'array'
|
|
570
|
+
AND json_array_length(turn_models) >= 1),
|
|
571
|
+
family TEXT NOT NULL,
|
|
572
|
+
body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64),
|
|
573
|
+
tool TEXT NOT NULL CHECK (tool = 'model-of'),
|
|
574
|
+
tool_sha256 TEXT NOT NULL CHECK (length(tool_sha256) = 64),
|
|
575
|
+
measured_at INTEGER NOT NULL,
|
|
576
|
+
policy_version INTEGER NOT NULL,
|
|
577
|
+
recorded_at INTEGER NOT NULL,
|
|
578
|
+
body TEXT NOT NULL,
|
|
579
|
+
sig TEXT NOT NULL,
|
|
580
|
+
FOREIGN KEY (decision_id, target_by, target_seq) REFERENCES approvals(decision_id, approver, seq),
|
|
581
|
+
UNIQUE(decision_id, target_by, target_seq, measurer)
|
|
582
|
+
) STRICT;
|
|
583
|
+
CREATE TRIGGER attest_no_update BEFORE UPDATE ON served_by_attestations
|
|
584
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
585
|
+
CREATE TRIGGER attest_no_delete BEFORE DELETE ON served_by_attestations
|
|
586
|
+
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
|
|
587
|
+
-- Rule 1: signed by an active principal holding the owner-assigned measurer role (never a voter)
|
|
588
|
+
CREATE TRIGGER attest_measurer BEFORE INSERT ON served_by_attestations
|
|
589
|
+
WHEN NOT EXISTS (SELECT 1 FROM fleet_principals f WHERE f.principal_id = NEW.measurer
|
|
590
|
+
AND f.kind = 'measurer' AND f.status = 'active')
|
|
591
|
+
BEGIN SELECT RAISE(ABORT, 'attest-not-measurer'); END;
|
|
592
|
+
-- Rule 1 (S3): per decision, the measurer is not its sponsor, its creator, an author, or any verdict signer
|
|
593
|
+
CREATE TRIGGER attest_measurer_excluded BEFORE INSERT ON served_by_attestations
|
|
594
|
+
WHEN NEW.measurer IS (SELECT d.sponsor FROM decisions d WHERE d.id = NEW.decision_id)
|
|
595
|
+
OR NEW.measurer IS (SELECT json_extract(d.body, '$.by') FROM decisions d WHERE d.id = NEW.decision_id)
|
|
596
|
+
OR EXISTS (SELECT 1 FROM decision_authors a WHERE a.decision_id = NEW.decision_id AND a.principal = NEW.measurer)
|
|
597
|
+
OR EXISTS (SELECT 1 FROM approvals a WHERE a.decision_id = NEW.decision_id AND a.approver = NEW.measurer)
|
|
598
|
+
BEGIN SELECT RAISE(ABORT, 'measurer-conflict'); END;
|
|
599
|
+
-- Rule 2: the hash the measurer found is the target verdict's body hash
|
|
600
|
+
CREATE TRIGGER attest_target_body BEFORE INSERT ON served_by_attestations
|
|
601
|
+
WHEN NEW.body_sha256 IS NOT (SELECT a.body_sha256 FROM approvals a WHERE a.decision_id = NEW.decision_id
|
|
602
|
+
AND a.approver = NEW.target_by AND a.seq = NEW.target_seq)
|
|
603
|
+
BEGIN SELECT RAISE(ABORT, 'attest-body-mismatch'); END;
|
|
604
|
+
-- Rule 4: the family is the current policy's map of the response model; unknown names map to nothing
|
|
605
|
+
CREATE TRIGGER attest_family_map BEFORE INSERT ON served_by_attestations
|
|
606
|
+
WHEN NEW.policy_version IS NOT (SELECT max(m.policy_version) FROM policy_family_map m)
|
|
607
|
+
OR (SELECT count(*) FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND NEW.response_model GLOB m.pattern) <> 1
|
|
608
|
+
OR NEW.family IS NOT (SELECT m.family FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND NEW.response_model GLOB m.pattern)
|
|
609
|
+
BEGIN SELECT RAISE(ABORT, 'attest-family-map'); END;
|
|
610
|
+
-- Rule 3: the response model is one of the turn's models, and every assistant record of the turn
|
|
611
|
+
-- maps to the response model's family. (An unmappable response model is rule 4's refusal.)
|
|
612
|
+
CREATE TRIGGER attest_unmixed BEFORE INSERT ON served_by_attestations
|
|
613
|
+
WHEN NOT EXISTS (SELECT 1 FROM json_each(NEW.turn_models) j WHERE j.value = NEW.response_model)
|
|
614
|
+
OR ((SELECT count(*) FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND NEW.response_model GLOB m.pattern) = 1
|
|
615
|
+
AND EXISTS (SELECT 1 FROM json_each(NEW.turn_models) j
|
|
616
|
+
WHERE (SELECT count(*) FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND j.value GLOB m.pattern) <> 1
|
|
617
|
+
OR (SELECT m.family FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND j.value GLOB m.pattern)
|
|
618
|
+
IS NOT (SELECT m.family FROM policy_family_map m WHERE m.policy_version = NEW.policy_version AND NEW.response_model GLOB m.pattern)))
|
|
619
|
+
BEGIN SELECT RAISE(ABORT, 'attest-mixed-turn'); END;
|
|
620
|
+
|
|
621
|
+
-- S4, E-1: security findings. Only the security reviewer opens or closes one; the owner has no
|
|
622
|
+
-- close and no veto over findings. An open finding against a member blocks its C2 promotion (10).
|
|
623
|
+
CREATE TABLE security_findings(
|
|
624
|
+
id TEXT PRIMARY KEY,
|
|
625
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
626
|
+
opened_by TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
627
|
+
opened_at INTEGER NOT NULL,
|
|
628
|
+
body TEXT NOT NULL,
|
|
629
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128),
|
|
630
|
+
closed_by TEXT REFERENCES fleet_principals(principal_id),
|
|
631
|
+
closed_at INTEGER,
|
|
632
|
+
close_body TEXT,
|
|
633
|
+
close_sig TEXT CHECK (close_sig IS NULL OR length(close_sig) = 128),
|
|
634
|
+
CHECK ((closed_at IS NULL) = (closed_by IS NULL) AND (closed_at IS NULL) = (close_sig IS NULL)
|
|
635
|
+
AND (closed_at IS NULL) = (close_body IS NULL)),
|
|
636
|
+
CHECK (closed_at IS NULL OR closed_at >= opened_at)
|
|
637
|
+
) STRICT;
|
|
638
|
+
CREATE TRIGGER security_findings_open BEFORE INSERT ON security_findings
|
|
639
|
+
WHEN NEW.closed_at IS NOT NULL
|
|
640
|
+
OR NOT EXISTS (SELECT 1 FROM fleet_principals f WHERE f.principal_id = NEW.opened_by AND f.kind = 'security' AND f.status = 'active')
|
|
641
|
+
BEGIN SELECT RAISE(ABORT, 'finding-not-security'); END;
|
|
642
|
+
CREATE TRIGGER security_findings_close_by_security BEFORE UPDATE ON security_findings
|
|
643
|
+
WHEN OLD.closed_at IS NULL AND NEW.closed_at IS NOT NULL
|
|
644
|
+
AND NOT EXISTS (SELECT 1 FROM fleet_principals f WHERE f.principal_id = NEW.closed_by AND f.kind = 'security' AND f.status = 'active')
|
|
645
|
+
BEGIN SELECT RAISE(ABORT, 'finding-not-security'); END;
|
|
646
|
+
CREATE TRIGGER security_findings_close_once BEFORE UPDATE ON security_findings
|
|
647
|
+
WHEN NEW.id IS NOT OLD.id OR NEW.member_id IS NOT OLD.member_id OR NEW.opened_by IS NOT OLD.opened_by
|
|
648
|
+
OR NEW.opened_at IS NOT OLD.opened_at OR NEW.body IS NOT OLD.body OR NEW.sig IS NOT OLD.sig
|
|
649
|
+
OR OLD.closed_at IS NOT NULL
|
|
650
|
+
BEGIN SELECT RAISE(ABORT, 'finding-fixed'); END;
|
|
651
|
+
CREATE TRIGGER security_findings_no_delete BEFORE DELETE ON security_findings
|
|
652
|
+
BEGIN SELECT RAISE(ABORT, 'finding-fixed'); END;
|
|
653
|
+
`;
|
|
654
|
+
|
|
655
|
+
export const EXTENSION_DDL = `-- Extension tables (L0 board, mailbox, ledgers and logs; spec v3.2 sections 7, 9.5, 9.6, 11, 12).
|
|
656
|
+
-- They never change a section 9.3 table's columns, so 9.3 rows keep their exact shape.
|
|
657
|
+
-- Guards here use their own error strings and never repeat a 9.3 guard.
|
|
658
|
+
|
|
659
|
+
CREATE TABLE lanes(
|
|
660
|
+
lane TEXT PRIMARY KEY,
|
|
661
|
+
owner_seat TEXT NOT NULL REFERENCES fleet_principals(principal_id)
|
|
662
|
+
) STRICT;
|
|
663
|
+
|
|
664
|
+
-- Member labels and heartbeat times. Labels are display only, never identity (5.2).
|
|
665
|
+
CREATE TABLE member_profiles(
|
|
666
|
+
member_id TEXT PRIMARY KEY REFERENCES members(member_id),
|
|
667
|
+
display_name TEXT NOT NULL,
|
|
668
|
+
platform TEXT NOT NULL,
|
|
669
|
+
lane TEXT NOT NULL REFERENCES lanes(lane),
|
|
670
|
+
created_at INTEGER NOT NULL,
|
|
671
|
+
first_heartbeat_at INTEGER,
|
|
672
|
+
last_heartbeat_at INTEGER
|
|
673
|
+
) STRICT;
|
|
674
|
+
CREATE TRIGGER profiles_identity_fixed BEFORE UPDATE ON member_profiles
|
|
675
|
+
WHEN NEW.member_id IS NOT OLD.member_id OR NEW.lane IS NOT OLD.lane OR NEW.created_at IS NOT OLD.created_at
|
|
676
|
+
OR (OLD.first_heartbeat_at IS NOT NULL AND NEW.first_heartbeat_at IS NOT OLD.first_heartbeat_at)
|
|
677
|
+
BEGIN SELECT RAISE(ABORT, 'profile-identity-fixed'); END;
|
|
678
|
+
CREATE TRIGGER profiles_no_delete BEFORE DELETE ON member_profiles
|
|
679
|
+
BEGIN SELECT RAISE(ABORT, 'profiles-permanent'); END;
|
|
680
|
+
|
|
681
|
+
CREATE TABLE tasks(
|
|
682
|
+
task_id TEXT PRIMARY KEY,
|
|
683
|
+
title TEXT NOT NULL,
|
|
684
|
+
description TEXT NOT NULL,
|
|
685
|
+
lane TEXT NOT NULL REFERENCES lanes(lane),
|
|
686
|
+
kind TEXT NOT NULL CHECK (kind IN ('work','improvement','review')),
|
|
687
|
+
acceptance TEXT NOT NULL,
|
|
688
|
+
paths TEXT NOT NULL,
|
|
689
|
+
base_commit TEXT NOT NULL CHECK (length(base_commit) = 40),
|
|
690
|
+
status TEXT NOT NULL CHECK (status IN ('open','claimed','in-review','held','accepted','closed','needs-triage')),
|
|
691
|
+
opened_by TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
692
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
693
|
+
created_at INTEGER NOT NULL,
|
|
694
|
+
updated_at INTEGER NOT NULL
|
|
695
|
+
) STRICT;
|
|
696
|
+
CREATE TRIGGER tasks_content_fixed BEFORE UPDATE ON tasks
|
|
697
|
+
WHEN NEW.task_id IS NOT OLD.task_id OR NEW.title IS NOT OLD.title OR NEW.description IS NOT OLD.description
|
|
698
|
+
OR NEW.lane IS NOT OLD.lane OR NEW.kind IS NOT OLD.kind OR NEW.acceptance IS NOT OLD.acceptance
|
|
699
|
+
OR NEW.paths IS NOT OLD.paths OR NEW.base_commit IS NOT OLD.base_commit OR NEW.opened_by IS NOT OLD.opened_by
|
|
700
|
+
OR NEW.created_at IS NOT OLD.created_at OR NEW.attempts < OLD.attempts
|
|
701
|
+
OR (OLD.status = 'closed' AND NEW.status IS NOT OLD.status)
|
|
702
|
+
-- C3: an accepted task leaves 'accepted' only for triage, once its accepted submission is reversed.
|
|
703
|
+
OR (OLD.status = 'accepted' AND NEW.status IS NOT OLD.status AND NOT (NEW.status = 'needs-triage' AND EXISTS (
|
|
704
|
+
SELECT 1 FROM submissions s JOIN submission_details x USING(submission_id) WHERE x.task_id = NEW.task_id AND s.state = 'reversed')))
|
|
705
|
+
BEGIN SELECT RAISE(ABORT, 'task-fixed'); END;
|
|
706
|
+
CREATE TRIGGER tasks_no_delete BEFORE DELETE ON tasks
|
|
707
|
+
BEGIN SELECT RAISE(ABORT, 'tasks-permanent'); END;
|
|
708
|
+
|
|
709
|
+
-- Board columns for claims (11.3). The 9.3 claims row keeps its shape; these sit beside it.
|
|
710
|
+
-- C3: the seat decision that opened a claim (task.assign, review.changes), so its reversal can release it.
|
|
711
|
+
CREATE TABLE claim_origins(
|
|
712
|
+
claim_id TEXT PRIMARY KEY REFERENCES claims(claim_id),
|
|
713
|
+
decision_id TEXT NOT NULL REFERENCES decisions(id)
|
|
714
|
+
) STRICT;
|
|
715
|
+
CREATE TRIGGER claim_origins_no_update BEFORE UPDATE ON claim_origins
|
|
716
|
+
BEGIN SELECT RAISE(ABORT, 'claim-origin-fixed'); END;
|
|
717
|
+
CREATE TRIGGER claim_origins_no_delete BEFORE DELETE ON claim_origins
|
|
718
|
+
BEGIN SELECT RAISE(ABORT, 'claim-origin-fixed'); END;
|
|
719
|
+
|
|
720
|
+
-- C4: what the creating seat intended for an invite (5.1), and the invite.create decision that made it.
|
|
721
|
+
CREATE TABLE invite_details(
|
|
722
|
+
invite_id TEXT PRIMARY KEY REFERENCES invites(invite_id),
|
|
723
|
+
decision_id TEXT NOT NULL UNIQUE REFERENCES decisions(id),
|
|
724
|
+
lane TEXT NOT NULL REFERENCES lanes(lane),
|
|
725
|
+
intended_display_name TEXT NOT NULL CHECK (length(intended_display_name) BETWEEN 1 AND 64),
|
|
726
|
+
intended_platform TEXT NOT NULL CHECK (length(intended_platform) BETWEEN 1 AND 64)
|
|
727
|
+
) STRICT;
|
|
728
|
+
CREATE TRIGGER invite_details_no_update BEFORE UPDATE ON invite_details
|
|
729
|
+
BEGIN SELECT RAISE(ABORT, 'invite-details-fixed'); END;
|
|
730
|
+
CREATE TRIGGER invite_details_no_delete BEFORE DELETE ON invite_details
|
|
731
|
+
BEGIN SELECT RAISE(ABORT, 'invite-details-fixed'); END;
|
|
732
|
+
|
|
733
|
+
CREATE TABLE claim_details(
|
|
734
|
+
claim_id TEXT PRIMARY KEY REFERENCES claims(claim_id),
|
|
735
|
+
lease_ms INTEGER NOT NULL CHECK (lease_ms > 0 AND lease_ms <= 86400000),
|
|
736
|
+
created_at INTEGER NOT NULL
|
|
737
|
+
) STRICT;
|
|
738
|
+
CREATE TRIGGER claim_details_no_update BEFORE UPDATE ON claim_details
|
|
739
|
+
BEGIN SELECT RAISE(ABORT, 'claim-details-fixed'); END;
|
|
740
|
+
CREATE TRIGGER claim_details_no_delete BEFORE DELETE ON claim_details
|
|
741
|
+
BEGIN SELECT RAISE(ABORT, 'claim-details-fixed'); END;
|
|
742
|
+
CREATE UNIQUE INDEX claims_task_epoch ON claims(task_id, epoch);
|
|
743
|
+
CREATE UNIQUE INDEX claims_one_live ON claims(task_id) WHERE state = 'live';
|
|
744
|
+
CREATE TRIGGER claims_epoch_rises BEFORE INSERT ON claims
|
|
745
|
+
WHEN NEW.epoch <= coalesce((SELECT max(epoch) FROM claims WHERE task_id = NEW.task_id), 0)
|
|
746
|
+
BEGIN SELECT RAISE(ABORT, 'epoch-must-rise'); END;
|
|
747
|
+
-- 9.3 claims_binding_immutable fixes the binding; the board adds that a live lease never moves back.
|
|
748
|
+
CREATE TRIGGER claims_lease_forward BEFORE UPDATE OF lease_until ON claims
|
|
749
|
+
WHEN OLD.state = 'live' AND NEW.lease_until < OLD.lease_until
|
|
750
|
+
BEGIN SELECT RAISE(ABORT, 'lease-moves-forward'); END;
|
|
751
|
+
|
|
752
|
+
-- Board columns for submissions (11, 12.1).
|
|
753
|
+
CREATE TABLE submission_details(
|
|
754
|
+
submission_id TEXT PRIMARY KEY REFERENCES submissions(submission_id),
|
|
755
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
756
|
+
claim_id TEXT NOT NULL UNIQUE REFERENCES claims(claim_id),
|
|
757
|
+
epoch INTEGER NOT NULL,
|
|
758
|
+
kind TEXT NOT NULL CHECK (kind IN ('patch','report')),
|
|
759
|
+
body TEXT NOT NULL,
|
|
760
|
+
body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64),
|
|
761
|
+
created_at INTEGER NOT NULL
|
|
762
|
+
) STRICT;
|
|
763
|
+
CREATE TRIGGER submission_details_no_update BEFORE UPDATE ON submission_details
|
|
764
|
+
BEGIN SELECT RAISE(ABORT, 'submission-content-fixed'); END;
|
|
765
|
+
CREATE TRIGGER submission_details_no_delete BEFORE DELETE ON submission_details
|
|
766
|
+
BEGIN SELECT RAISE(ABORT, 'submission-content-fixed'); END;
|
|
767
|
+
-- Accepted and rejected are final, except accepted -> reversed, which the 9.3 guard
|
|
768
|
+
-- submissions_reversed owns (C3), as it owns every transition out of reversed.
|
|
769
|
+
-- Acceptance comes from review (held is guarded by 9.3).
|
|
770
|
+
CREATE TRIGGER submissions_final BEFORE UPDATE OF state ON submissions
|
|
771
|
+
WHEN (OLD.state IN ('accepted','rejected') AND NEW.state IS NOT OLD.state AND NOT (OLD.state = 'accepted' AND NEW.state = 'reversed'))
|
|
772
|
+
OR (NEW.state = 'accepted' AND OLD.state NOT IN ('in-review','held','reversed'))
|
|
773
|
+
BEGIN SELECT RAISE(ABORT, 'submission-final'); END;
|
|
774
|
+
|
|
775
|
+
-- Fleet co-authors of a submission (V5): declared before any decision about it.
|
|
776
|
+
CREATE TABLE coauthors(
|
|
777
|
+
submission_id TEXT NOT NULL REFERENCES submissions(submission_id),
|
|
778
|
+
principal TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
779
|
+
served_by TEXT NOT NULL CHECK (length(served_by) > 0),
|
|
780
|
+
at INTEGER NOT NULL,
|
|
781
|
+
body TEXT NOT NULL,
|
|
782
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128),
|
|
783
|
+
PRIMARY KEY(submission_id, principal)
|
|
784
|
+
) STRICT;
|
|
785
|
+
CREATE TRIGGER coauthors_before_decisions BEFORE INSERT ON coauthors
|
|
786
|
+
WHEN EXISTS (SELECT 1 FROM decisions WHERE subject = NEW.submission_id)
|
|
787
|
+
BEGIN SELECT RAISE(ABORT, 'coauthors-closed'); END;
|
|
788
|
+
CREATE TRIGGER coauthors_no_update BEFORE UPDATE ON coauthors
|
|
789
|
+
BEGIN SELECT RAISE(ABORT, 'coauthors-fixed'); END;
|
|
790
|
+
CREATE TRIGGER coauthors_no_delete BEFORE DELETE ON coauthors
|
|
791
|
+
BEGIN SELECT RAISE(ABORT, 'coauthors-fixed'); END;
|
|
792
|
+
|
|
793
|
+
CREATE TABLE progress(
|
|
794
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
795
|
+
record_id TEXT NOT NULL,
|
|
796
|
+
claim_id TEXT NOT NULL REFERENCES claims(claim_id),
|
|
797
|
+
text TEXT NOT NULL,
|
|
798
|
+
at INTEGER NOT NULL,
|
|
799
|
+
PRIMARY KEY(member_id, record_id)
|
|
800
|
+
) STRICT;
|
|
801
|
+
CREATE TRIGGER progress_no_update BEFORE UPDATE ON progress BEGIN SELECT RAISE(ABORT, 'progress-fixed'); END;
|
|
802
|
+
CREATE TRIGGER progress_no_delete BEFORE DELETE ON progress BEGIN SELECT RAISE(ABORT, 'progress-fixed'); END;
|
|
803
|
+
|
|
804
|
+
CREATE TABLE evidence(
|
|
805
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
806
|
+
record_id TEXT NOT NULL,
|
|
807
|
+
submission_id TEXT NOT NULL REFERENCES submissions(submission_id),
|
|
808
|
+
kind TEXT NOT NULL,
|
|
809
|
+
text TEXT NOT NULL,
|
|
810
|
+
at INTEGER NOT NULL,
|
|
811
|
+
PRIMARY KEY(member_id, record_id)
|
|
812
|
+
) STRICT;
|
|
813
|
+
CREATE TRIGGER evidence_no_update BEFORE UPDATE ON evidence BEGIN SELECT RAISE(ABORT, 'evidence-fixed'); END;
|
|
814
|
+
CREATE TRIGGER evidence_no_delete BEFORE DELETE ON evidence BEGIN SELECT RAISE(ABORT, 'evidence-fixed'); END;
|
|
815
|
+
|
|
816
|
+
CREATE TABLE verify_results(
|
|
817
|
+
submission_id TEXT NOT NULL REFERENCES submissions(submission_id),
|
|
818
|
+
verifier TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
819
|
+
passed INTEGER NOT NULL CHECK (passed IN (0,1)),
|
|
820
|
+
patch_sha256 TEXT NOT NULL,
|
|
821
|
+
served_by TEXT NOT NULL CHECK (length(served_by) > 0),
|
|
822
|
+
at INTEGER NOT NULL,
|
|
823
|
+
body TEXT NOT NULL,
|
|
824
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128),
|
|
825
|
+
PRIMARY KEY(submission_id, verifier)
|
|
826
|
+
) STRICT;
|
|
827
|
+
CREATE TRIGGER verify_results_no_update BEFORE UPDATE ON verify_results BEGIN SELECT RAISE(ABORT, 'verify-result-fixed'); END;
|
|
828
|
+
CREATE TRIGGER verify_results_no_delete BEFORE DELETE ON verify_results BEGIN SELECT RAISE(ABORT, 'verify-result-fixed'); END;
|
|
829
|
+
|
|
830
|
+
CREATE TABLE security_approvals(
|
|
831
|
+
submission_id TEXT NOT NULL REFERENCES submissions(submission_id),
|
|
832
|
+
reviewer TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
833
|
+
served_by TEXT NOT NULL CHECK (length(served_by) > 0),
|
|
834
|
+
at INTEGER NOT NULL,
|
|
835
|
+
body TEXT NOT NULL,
|
|
836
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128),
|
|
837
|
+
PRIMARY KEY(submission_id, reviewer)
|
|
838
|
+
) STRICT;
|
|
839
|
+
CREATE TRIGGER security_approvals_no_update BEFORE UPDATE ON security_approvals BEGIN SELECT RAISE(ABORT, 'security-approval-fixed'); END;
|
|
840
|
+
CREATE TRIGGER security_approvals_no_delete BEFORE DELETE ON security_approvals BEGIN SELECT RAISE(ABORT, 'security-approval-fixed'); END;
|
|
841
|
+
|
|
842
|
+
CREATE TABLE contributions(
|
|
843
|
+
submission_id TEXT PRIMARY KEY REFERENCES submissions(submission_id),
|
|
844
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
845
|
+
decision_id TEXT NOT NULL REFERENCES decisions(id),
|
|
846
|
+
accepted_at INTEGER NOT NULL,
|
|
847
|
+
day TEXT NOT NULL
|
|
848
|
+
) STRICT;
|
|
849
|
+
CREATE TRIGGER contributions_no_update BEFORE UPDATE ON contributions BEGIN SELECT RAISE(ABORT, 'contribution-fixed'); END;
|
|
850
|
+
CREATE TRIGGER contributions_no_delete BEFORE DELETE ON contributions BEGIN SELECT RAISE(ABORT, 'contribution-fixed'); END;
|
|
851
|
+
|
|
852
|
+
-- The signed owner-record.confirm records (9.5) beside the outbox row they confirm.
|
|
853
|
+
CREATE TABLE owner_record_confirmations(
|
|
854
|
+
outbox_id TEXT PRIMARY KEY REFERENCES owner_record_outbox(id),
|
|
855
|
+
confirmed_by TEXT NOT NULL REFERENCES fleet_principals(principal_id),
|
|
856
|
+
at INTEGER NOT NULL,
|
|
857
|
+
body TEXT NOT NULL,
|
|
858
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128)
|
|
859
|
+
) STRICT;
|
|
860
|
+
CREATE TRIGGER owner_confirmations_no_update BEFORE UPDATE ON owner_record_confirmations BEGIN SELECT RAISE(ABORT, 'owner-confirmation-fixed'); END;
|
|
861
|
+
CREATE TRIGGER owner_confirmations_no_delete BEFORE DELETE ON owner_record_confirmations BEGIN SELECT RAISE(ABORT, 'owner-confirmation-fixed'); END;
|
|
862
|
+
|
|
863
|
+
-- D0 progress.accept (9.2): a lane owner accepts one progress record, once.
|
|
864
|
+
CREATE TABLE progress_acceptances(
|
|
865
|
+
member_id TEXT NOT NULL,
|
|
866
|
+
record_id TEXT NOT NULL,
|
|
867
|
+
decision_id TEXT NOT NULL UNIQUE REFERENCES decisions(id),
|
|
868
|
+
at INTEGER NOT NULL,
|
|
869
|
+
PRIMARY KEY(member_id, record_id),
|
|
870
|
+
FOREIGN KEY(member_id, record_id) REFERENCES progress(member_id, record_id)
|
|
871
|
+
) STRICT;
|
|
872
|
+
CREATE TRIGGER progress_acceptances_no_update BEFORE UPDATE ON progress_acceptances BEGIN SELECT RAISE(ABORT, 'progress-acceptance-fixed'); END;
|
|
873
|
+
CREATE TRIGGER progress_acceptances_no_delete BEFORE DELETE ON progress_acceptances BEGIN SELECT RAISE(ABORT, 'progress-acceptance-fixed'); END;
|
|
874
|
+
|
|
875
|
+
-- L1 request nonces (6.1 step 5, B9): UNIQUE(principal, nonce), spent before the future check and
|
|
876
|
+
-- kept spent even if a later step fails; pruned after 10 minutes (the 6-minute acceptance span).
|
|
877
|
+
CREATE TABLE request_nonces(
|
|
878
|
+
principal TEXT NOT NULL,
|
|
879
|
+
nonce TEXT NOT NULL CHECK (length(nonce) BETWEEN 32 AND 64 AND nonce NOT GLOB '*[^0-9a-f]*'),
|
|
880
|
+
expires_at INTEGER NOT NULL,
|
|
881
|
+
PRIMARY KEY(principal, nonce)
|
|
882
|
+
) STRICT;
|
|
883
|
+
|
|
884
|
+
-- L1 enrollment-flow failures (5.2, M1): one row per failure at steps 4-8 against an existing invite,
|
|
885
|
+
-- persisted so the budget survives a restart. Five failures against one invite void it. Append-only.
|
|
886
|
+
CREATE TABLE enroll_attempts(
|
|
887
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
888
|
+
invite_id TEXT NOT NULL REFERENCES invites(invite_id),
|
|
889
|
+
kind TEXT NOT NULL CHECK (kind IN ('enroll','rekey','rebind')),
|
|
890
|
+
step INTEGER NOT NULL CHECK (step BETWEEN 4 AND 8),
|
|
891
|
+
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 64),
|
|
892
|
+
source_ip TEXT NOT NULL CHECK (length(source_ip) BETWEEN 2 AND 64),
|
|
893
|
+
at INTEGER NOT NULL
|
|
894
|
+
) STRICT;
|
|
895
|
+
CREATE TRIGGER enroll_attempts_no_update BEFORE UPDATE ON enroll_attempts BEGIN SELECT RAISE(ABORT, 'enroll-attempt-fixed'); END;
|
|
896
|
+
CREATE TRIGGER enroll_attempts_no_delete BEFORE DELETE ON enroll_attempts BEGIN SELECT RAISE(ABORT, 'enroll-attempt-fixed'); END;
|
|
897
|
+
|
|
898
|
+
-- The owner-signed roster and policy files as loaded (9.1, 9.2 D3), so an export carries them and
|
|
899
|
+
-- the verifier re-derives fleet_principals, lanes and policy_family_map from them (v3.3.2 E-3).
|
|
900
|
+
CREATE TABLE signed_files(
|
|
901
|
+
kind TEXT NOT NULL CHECK (kind IN ('roster','policy')),
|
|
902
|
+
version INTEGER NOT NULL CHECK (version >= 1),
|
|
903
|
+
body TEXT NOT NULL,
|
|
904
|
+
sig TEXT NOT NULL CHECK (length(sig) = 128),
|
|
905
|
+
loaded_at INTEGER NOT NULL,
|
|
906
|
+
PRIMARY KEY(kind, version)
|
|
907
|
+
) STRICT;
|
|
908
|
+
CREATE TRIGGER signed_files_no_update BEFORE UPDATE ON signed_files BEGIN SELECT RAISE(ABORT, 'signed-file-fixed'); END;
|
|
909
|
+
CREATE TRIGGER signed_files_no_delete BEFORE DELETE ON signed_files BEGIN SELECT RAISE(ABORT, 'signed-file-fixed'); END;
|
|
910
|
+
|
|
911
|
+
-- Idempotency (11.4): UNIQUE(member, record_id). A retry returns the stored receipt.
|
|
912
|
+
CREATE TABLE member_records(
|
|
913
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
914
|
+
record_id TEXT NOT NULL,
|
|
915
|
+
type TEXT NOT NULL,
|
|
916
|
+
record_sha256 TEXT NOT NULL CHECK (length(record_sha256) = 64),
|
|
917
|
+
receipt TEXT NOT NULL,
|
|
918
|
+
at INTEGER NOT NULL,
|
|
919
|
+
PRIMARY KEY(member_id, record_id)
|
|
920
|
+
) STRICT;
|
|
921
|
+
CREATE TRIGGER member_records_no_update BEFORE UPDATE ON member_records BEGIN SELECT RAISE(ABORT, 'member-record-fixed'); END;
|
|
922
|
+
CREATE TRIGGER member_records_no_delete BEFORE DELETE ON member_records BEGIN SELECT RAISE(ABORT, 'member-record-fixed'); END;
|
|
923
|
+
|
|
924
|
+
-- Store-and-forward mailbox (11.5). seq is per member and never reused.
|
|
925
|
+
CREATE TABLE mailbox_state(
|
|
926
|
+
member_id TEXT PRIMARY KEY REFERENCES members(member_id),
|
|
927
|
+
next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1),
|
|
928
|
+
acked_through INTEGER NOT NULL DEFAULT 0 CHECK (acked_through >= 0)
|
|
929
|
+
) STRICT;
|
|
930
|
+
CREATE TRIGGER mailbox_state_forward BEFORE UPDATE ON mailbox_state
|
|
931
|
+
WHEN NEW.next_seq < OLD.next_seq OR NEW.acked_through < OLD.acked_through OR NEW.acked_through >= NEW.next_seq
|
|
932
|
+
BEGIN SELECT RAISE(ABORT, 'mailbox-cursor-moves-forward'); END;
|
|
933
|
+
CREATE TABLE mailbox(
|
|
934
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
935
|
+
seq INTEGER NOT NULL CHECK (seq >= 1),
|
|
936
|
+
record_id TEXT NOT NULL,
|
|
937
|
+
sender TEXT NOT NULL,
|
|
938
|
+
type TEXT NOT NULL,
|
|
939
|
+
body TEXT NOT NULL,
|
|
940
|
+
body_sha256 TEXT NOT NULL,
|
|
941
|
+
bytes INTEGER NOT NULL,
|
|
942
|
+
created_at INTEGER NOT NULL,
|
|
943
|
+
acked_at INTEGER,
|
|
944
|
+
expired_at INTEGER,
|
|
945
|
+
PRIMARY KEY(member_id, seq),
|
|
946
|
+
UNIQUE(member_id, record_id)
|
|
947
|
+
) STRICT;
|
|
948
|
+
CREATE TRIGGER mailbox_record_fixed BEFORE UPDATE ON mailbox
|
|
949
|
+
WHEN NEW.member_id IS NOT OLD.member_id OR NEW.seq IS NOT OLD.seq OR NEW.record_id IS NOT OLD.record_id
|
|
950
|
+
OR NEW.sender IS NOT OLD.sender OR NEW.type IS NOT OLD.type OR NEW.body IS NOT OLD.body
|
|
951
|
+
OR NEW.body_sha256 IS NOT OLD.body_sha256 OR NEW.bytes IS NOT OLD.bytes OR NEW.created_at IS NOT OLD.created_at
|
|
952
|
+
BEGIN SELECT RAISE(ABORT, 'mailbox-record-fixed'); END;
|
|
953
|
+
|
|
954
|
+
-- Member-to-seat feed (7.5) and the quarantine for notes (7.4).
|
|
955
|
+
CREATE TABLE seat_feed(
|
|
956
|
+
feed_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
957
|
+
lane TEXT NOT NULL,
|
|
958
|
+
member_id TEXT NOT NULL REFERENCES members(member_id),
|
|
959
|
+
record_id TEXT NOT NULL,
|
|
960
|
+
type TEXT NOT NULL,
|
|
961
|
+
body TEXT NOT NULL,
|
|
962
|
+
at INTEGER NOT NULL,
|
|
963
|
+
state TEXT NOT NULL CHECK (state IN ('pending','delivered','dropped'))
|
|
964
|
+
) STRICT;
|
|
965
|
+
CREATE TABLE ext_quarantine(
|
|
966
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
967
|
+
member_id TEXT NOT NULL,
|
|
968
|
+
record_id TEXT NOT NULL,
|
|
969
|
+
body TEXT NOT NULL,
|
|
970
|
+
at INTEGER NOT NULL
|
|
971
|
+
) STRICT;
|
|
972
|
+
CREATE TRIGGER ext_quarantine_no_update BEFORE UPDATE ON ext_quarantine BEGIN SELECT RAISE(ABORT, 'quarantine-fixed'); END;
|
|
973
|
+
CREATE TRIGGER ext_quarantine_no_delete BEFORE DELETE ON ext_quarantine BEGIN SELECT RAISE(ABORT, 'quarantine-fixed'); END;
|
|
974
|
+
|
|
975
|
+
-- Hash-chained append-only logs (9.6) and seat-readable alerts.
|
|
976
|
+
CREATE TABLE logs(
|
|
977
|
+
log TEXT NOT NULL CHECK (log IN ('decision','board','security','enrollment','mailbox')),
|
|
978
|
+
seq INTEGER NOT NULL CHECK (seq >= 1),
|
|
979
|
+
at INTEGER NOT NULL,
|
|
980
|
+
event TEXT NOT NULL,
|
|
981
|
+
prev TEXT NOT NULL,
|
|
982
|
+
hash TEXT NOT NULL,
|
|
983
|
+
PRIMARY KEY(log, seq)
|
|
984
|
+
) STRICT;
|
|
985
|
+
CREATE TRIGGER logs_no_update BEFORE UPDATE ON logs BEGIN SELECT RAISE(ABORT, 'logs-fixed'); END;
|
|
986
|
+
CREATE TRIGGER logs_no_delete BEFORE DELETE ON logs BEGIN SELECT RAISE(ABORT, 'logs-fixed'); END;
|
|
987
|
+
CREATE TABLE alerts(
|
|
988
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
989
|
+
at INTEGER NOT NULL,
|
|
990
|
+
kind TEXT NOT NULL,
|
|
991
|
+
subject TEXT,
|
|
992
|
+
detail TEXT NOT NULL
|
|
993
|
+
) STRICT;
|
|
994
|
+
CREATE TRIGGER alerts_no_update BEFORE UPDATE ON alerts BEGIN SELECT RAISE(ABORT, 'alerts-fixed'); END;
|
|
995
|
+
CREATE TRIGGER alerts_no_delete BEFORE DELETE ON alerts BEGIN SELECT RAISE(ABORT, 'alerts-fixed'); END;
|
|
996
|
+
`;
|
|
997
|
+
|
|
998
|
+
export const DDL = CORE_DDL + "\n" + EXTENSION_DDL;
|
|
999
|
+
|
|
1000
|
+
const META = "CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT NOT NULL) STRICT;";
|
|
1001
|
+
|
|
1002
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1003
|
+
/**
|
|
1004
|
+
* N6: switching to WAL needs a lock that SQLite's busy handler does not wait for, and it
|
|
1005
|
+
* cannot run inside a transaction. While another process holds the schema-creation lock it
|
|
1006
|
+
* returns "database is locked" at once, so it is retried for up to the busy timeout (5 s).
|
|
1007
|
+
*/
|
|
1008
|
+
function setWalMode(db) {
|
|
1009
|
+
for (let attempt = 1; ; attempt++) {
|
|
1010
|
+
try { db.exec("PRAGMA journal_mode = WAL;"); return; }
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
if (attempt >= 250 || !/database is locked/.test(String(error.message))) throw error;
|
|
1013
|
+
pause(20);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Foreign keys must be on for every connection; refuse a connection where they are not (9.3). */
|
|
1019
|
+
export function assertForeignKeys(db) {
|
|
1020
|
+
if (db.prepare("PRAGMA foreign_keys").get().foreign_keys !== 1) throw new Error("relay.db refuses to open without PRAGMA foreign_keys = ON");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Open (or create) a relay database. Scratch-only guard (R6): the file must be
|
|
1025
|
+
* named relay*.db, so commons.db, the console database or any other database is
|
|
1026
|
+
* refused before it is opened. The schema is created once, in one transaction.
|
|
1027
|
+
*/
|
|
1028
|
+
export function openRelayDb(path = ":memory:") {
|
|
1029
|
+
if (path !== ":memory:") {
|
|
1030
|
+
if (!/^relay[A-Za-z0-9._-]*\.db$/.test(basename(path))) throw new Error("Relay refuses any database not named relay*.db");
|
|
1031
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1032
|
+
}
|
|
1033
|
+
const db = new DatabaseSync(path);
|
|
1034
|
+
try {
|
|
1035
|
+
db.exec("PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;");
|
|
1036
|
+
setWalMode(db);
|
|
1037
|
+
assertForeignKeys(db);
|
|
1038
|
+
// N6: the schema is created once. The version is read inside an EXCLUSIVE transaction,
|
|
1039
|
+
// so two processes opening a fresh file cannot both run the DDL; the second waits,
|
|
1040
|
+
// then finds the version the first wrote.
|
|
1041
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
1042
|
+
let version;
|
|
1043
|
+
try {
|
|
1044
|
+
db.exec(META);
|
|
1045
|
+
version = db.prepare("SELECT v FROM meta WHERE k = 'schema_version'").get()?.v;
|
|
1046
|
+
if (version === undefined) {
|
|
1047
|
+
db.exec(DDL);
|
|
1048
|
+
const set = db.prepare("INSERT INTO meta(k, v) VALUES (?, ?)");
|
|
1049
|
+
set.run("schema_version", String(SCHEMA_VERSION));
|
|
1050
|
+
set.run("epoch", randomBytes(16).toString("hex"));
|
|
1051
|
+
set.run("spec_pin", SPEC_PIN.commit);
|
|
1052
|
+
version = String(SCHEMA_VERSION);
|
|
1053
|
+
}
|
|
1054
|
+
db.exec("COMMIT");
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
try { db.exec("ROLLBACK"); } catch { /* the original error is the one that matters */ }
|
|
1057
|
+
throw error;
|
|
1058
|
+
}
|
|
1059
|
+
if (Number(version) !== SCHEMA_VERSION) throw new Error(`relay.db schema ${version} is not ${SCHEMA_VERSION}`);
|
|
1060
|
+
return db;
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
db.close();
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Run fn inside BEGIN IMMEDIATE ... COMMIT; roll back on any throw. */
|
|
1068
|
+
export function tx(db, fn) {
|
|
1069
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1070
|
+
try {
|
|
1071
|
+
const result = fn();
|
|
1072
|
+
db.exec("COMMIT");
|
|
1073
|
+
return result;
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
try { db.exec("ROLLBACK"); } catch { /* the original error is the one that matters */ }
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export const getMeta = (db, k) => db.prepare("SELECT v FROM meta WHERE k = ?").get(k)?.v ?? null;
|
|
1081
|
+
export const setMeta = (db, k, v) => db.prepare("INSERT INTO meta(k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(k, String(v));
|