Skip to main content

gwk_domain/
contract_sql.rs

1// The backend-neutral contract DDL, embedded from schema/0001_contract.sql
2// by `cargo run -p xtask -- contract`.
3// DO NOT EDIT — regenerate instead; CI diffs this file against the source.
4
5/// Every byte of `schema/0001_contract.sql`. The script wraps itself in
6/// `BEGIN`/`COMMIT`, so it is applied whole and never inside another
7/// transaction.
8pub const CONTRACT_SQL: &str = r#"-- 0001_contract.sql — the gwk contract schema, greenfield.
9--
10-- This file is CONTRACT: shapes, state sets, legal edges, and invariants any
11-- conforming storage backend must uphold. Engine-specific mechanics (queues,
12-- notification channels, lock strategies, privilege hardening) belong to the
13-- backend/deployment layer and are deliberately absent here.
14--
15-- Conventions:
16--   * every identifier snake_case; ids are opaque text
17--   * state values are exactly the wire strings of gwk-domain's FSM enums
18--   * u64 counters (seq, fence_token, byte_size) are numeric(20,0) IN STORAGE,
19--     CHECK-bounded to [0, 2^64-1] (18446744073709551615) — numeric(20,0)
20--     alone admits ±(10^20-1), overshooting u64 in both directions. A signed
21--     bigint tops out at 2^63-1, half the u64 wire range; the decimal-string
22--     rule is a WIRE-format rule (JSON), not a storage rule
23--   * u32 version counters (aggregate_version, each state row's version) are
24--     bigint IN STORAGE, CHECK-bounded to [1, 2^32-1] (4294967295) — a signed
25--     integer tops out at 2^31-1, half the u32 wire range
26--   * timestamps are timestamptz
27
28BEGIN;
29
30CREATE SCHEMA IF NOT EXISTS gwk;
31
32-- ============================================================
33-- The event log
34-- ============================================================
35
36-- seq is ASSIGNED BY THE KERNEL APPEND ACTOR, in COMMIT order, and is the
37-- global read/replay cursor: unique, strictly increasing, NOT gapless.
38--
39-- Deliberately NOT BIGSERIAL/IDENTITY: sequence numbers allocate at INSERT
40-- time but transactions COMMIT in a different order, so an allocation-ordered
41-- column is not proof of commit order — a reader paging by it can observe
42-- seq N+1 before an uncommitted seq N exists, then never see N. A single
43-- append actor assigning seq at commit closes that hole by construction.
44CREATE TABLE gwk.event (
45  seq               numeric(20,0) PRIMARY KEY
46                      CHECK (seq >= 0 AND seq <= 18446744073709551615),
47  event_id          text NOT NULL UNIQUE,
48  project_id        text NOT NULL,
49  aggregate_type    text NOT NULL,
50  aggregate_id      text NOT NULL,
51  aggregate_version bigint NOT NULL CHECK (aggregate_version BETWEEN 1 AND 4294967295),
52  event_type        text NOT NULL,
53  -- bigint, not integer: the envelope declares `schema_version: u32`, and a
54  -- signed integer tops out at 2^31-1 — half that range. The narrow column made
55  -- the contract claim a width the storage could not hold, on the one field a
56  -- future decoder dispatches on. Same treatment as aggregate_version above.
57  schema_version    bigint NOT NULL CHECK (schema_version BETWEEN 1 AND 4294967295),
58  occurred_at       timestamptz NOT NULL,
59  appended_at       timestamptz NOT NULL,
60  actor             jsonb NOT NULL,
61  origin            jsonb NOT NULL,
62  causation_id      text,
63  correlation_id    text,
64  idempotency_key   text,
65  payload           jsonb NOT NULL,
66  payload_ref       jsonb,
67  -- one event per aggregate version: the contiguity + CAS anchor
68  UNIQUE (aggregate_type, aggregate_id, aggregate_version)
69);
70
71-- retry-stable appends: the same keyed write cannot land twice per aggregate.
72-- This is the index the REPLAY lookup reads — a retry of the same command on
73-- the same aggregate is answered from here with the original events.
74CREATE UNIQUE INDEX event_idempotency
75  ON gwk.event (aggregate_type, aggregate_id, idempotency_key)
76  WHERE idempotency_key IS NOT NULL;
77
78-- Idempotency is GLOBAL per (project_id, idempotency_key), not per aggregate:
79-- one key names one request, and reusing it for a different one is a caller bug
80-- that must be refused rather than silently landed twice. The per-aggregate
81-- index above cannot see that case — the two writes differ in aggregate_id, so
82-- it admits both. This is the guard; the kernel's own pre-check turns the
83-- violation into a typed `idempotency_conflict` before it reaches the index.
84CREATE UNIQUE INDEX event_idempotency_project
85  ON gwk.event (project_id, idempotency_key)
86  WHERE idempotency_key IS NOT NULL;
87
88-- Append-only is a CONTRACT property, so it is enforced here as a trigger any
89-- backend inherits. Privilege-level enforcement (revoking UPDATE/DELETE from
90-- runtime roles) is a deployment MECHANISM layered on top by the kernel's
91-- provisioning, not part of the contract.
92CREATE FUNCTION gwk.forbid_event_mutation() RETURNS trigger
93LANGUAGE plpgsql AS $$
94BEGIN
95  RAISE EXCEPTION 'gwk.event is append-only (% refused)', TG_OP;
96END $$;
97
98CREATE TRIGGER event_append_only
99  BEFORE UPDATE OR DELETE ON gwk.event
100  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_event_mutation();
101
102-- Row-level triggers never fire on TRUNCATE, so append-only needs a
103-- statement-level trigger too — without it one statement erases the log.
104CREATE TRIGGER event_no_truncate
105  BEFORE TRUNCATE ON gwk.event
106  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_event_mutation();
107
108-- ENABLE ALWAYS on every guard trigger in this file: ordinary triggers are
109-- suppressed under SET session_replication_role = 'replica', which would
110-- otherwise switch every invariant off in one statement.
111ALTER TABLE gwk.event ENABLE ALWAYS TRIGGER event_append_only;
112ALTER TABLE gwk.event ENABLE ALWAYS TRIGGER event_no_truncate;
113
114-- ============================================================
115-- FSM edge data + the transition guard
116-- ============================================================
117
118CREATE TABLE gwk.transition (
119  entity     text NOT NULL,
120  from_state text NOT NULL,
121  to_state   text NOT NULL,
122  PRIMARY KEY (entity, from_state, to_state)
123);
124
125INSERT INTO gwk.transition (entity, from_state, to_state) VALUES
126  ('task', 'submitted', 'working'),
127  ('task', 'submitted', 'canceled'),
128  ('task', 'working', 'input_required'),
129  ('task', 'working', 'completed'),
130  ('task', 'working', 'failed'),
131  ('task', 'working', 'canceled'),
132  ('task', 'input_required', 'working'),
133  ('task', 'input_required', 'canceled'),
134
135  ('attempt', 'queued', 'leased'),
136  ('attempt', 'queued', 'canceled'),
137  ('attempt', 'leased', 'starting'),
138  ('attempt', 'leased', 'canceled'),
139  ('attempt', 'starting', 'running'),
140  ('attempt', 'starting', 'failed'),
141  ('attempt', 'starting', 'unknown'),
142  ('attempt', 'starting', 'canceling'),
143  ('attempt', 'running', 'blocked'),
144  ('attempt', 'running', 'canceling'),
145  ('attempt', 'running', 'failed'),
146  ('attempt', 'running', 'unknown'),
147  ('attempt', 'running', 'succeeded'),
148  ('attempt', 'blocked', 'running'),
149  ('attempt', 'blocked', 'canceling'),
150  ('attempt', 'blocked', 'failed'),
151  ('attempt', 'blocked', 'unknown'),
152  ('attempt', 'canceling', 'canceled'),
153  ('attempt', 'canceling', 'unknown'),
154
155  ('message', 'accepted', 'delivered'),
156  ('message', 'accepted', 'dead_letter'),
157  ('message', 'delivered', 'acknowledged'),
158  ('message', 'delivered', 'dead_letter'),
159  ('message', 'acknowledged', 'applied'),
160  ('message', 'acknowledged', 'rejected'),
161  ('message', 'acknowledged', 'dead_letter'),
162
163  ('command', 'issued', 'targeted'),
164  ('command', 'targeted', 'signaled'),
165  ('command', 'signaled', 'verification_complete');
166
167-- The edge table is the data the transition guard trusts: a plain INSERT here
168-- would legalise a previously forbidden edge for every subsequent transition.
169-- Seeded above, then frozen — these triggers refuse every later write.
170CREATE FUNCTION gwk.forbid_transition_mutation() RETURNS trigger
171LANGUAGE plpgsql AS $$
172BEGIN
173  RAISE EXCEPTION 'gwk.transition is immutable seed data (% refused)', TG_OP;
174END $$;
175
176CREATE TRIGGER transition_immutable
177  BEFORE INSERT OR UPDATE OR DELETE ON gwk.transition
178  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_transition_mutation();
179
180CREATE TRIGGER transition_no_truncate
181  BEFORE TRUNCATE ON gwk.transition
182  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_transition_mutation();
183
184ALTER TABLE gwk.transition ENABLE ALWAYS TRIGGER transition_immutable;
185ALTER TABLE gwk.transition ENABLE ALWAYS TRIGGER transition_no_truncate;
186
187-- The row-level guard behind every FSM table: edge legality against
188-- gwk.transition, and CAS discipline — EVERY update advances version by
189-- exactly 1 (terminal immutability follows from terminals having no edges).
190-- NOT enforced here: the liveness-producer flip rule (which actor may drive
191-- running <-> blocked) lives in the domain crate's transition apply path —
192-- every writer must route through it.
193CREATE FUNCTION gwk.assert_transition() RETURNS trigger
194LANGUAGE plpgsql AS $$
195BEGIN
196  IF NEW.state IS DISTINCT FROM OLD.state
197     AND NOT EXISTS (
198       SELECT 1 FROM gwk.transition t
199       WHERE t.entity = TG_ARGV[0]
200         AND t.from_state = OLD.state
201         AND t.to_state = NEW.state
202     ) THEN
203    RAISE EXCEPTION 'illegal % transition: % -> %', TG_ARGV[0], OLD.state, NEW.state;
204  END IF;
205  IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
206    RAISE EXCEPTION '% version must advance by exactly 1 (got % -> %)',
207      TG_ARGV[0], OLD.version, NEW.version;
208  END IF;
209  RETURN NEW;
210END $$;
211
212-- Companion guards on every FSM table. A row must be BORN at its machine's
213-- initial state at version 1: a row inserted mid-spine — or born terminal,
214-- e.g. a command inserted directly at verification_complete — satisfies every
215-- CHECK yet skips the whole edge guard. And rows are never deleted:
216-- DELETE-then-INSERT would walk around the edge table the same way.
217CREATE FUNCTION gwk.assert_initial_state() RETURNS trigger
218LANGUAGE plpgsql AS $$
219BEGIN
220  IF NEW.state IS DISTINCT FROM TG_ARGV[1] OR NEW.version IS DISTINCT FROM 1 THEN
221    RAISE EXCEPTION '% rows must be born in state % at version 1 (got %, version %)',
222      TG_ARGV[0], TG_ARGV[1], NEW.state, NEW.version;
223  END IF;
224  RETURN NEW;
225END $$;
226
227CREATE FUNCTION gwk.forbid_state_row_delete() RETURNS trigger
228LANGUAGE plpgsql AS $$
229BEGIN
230  RAISE EXCEPTION '% rows cannot be deleted', TG_ARGV[0];
231END $$;
232
233-- ============================================================
234-- Vertical-slice entity tables
235-- ============================================================
236
237CREATE TABLE gwk.lease (
238  id           text PRIMARY KEY,
239  version      bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
240  state        text NOT NULL DEFAULT 'held'
241                 CHECK (state IN ('held', 'released', 'expired')),
242  mode         text NOT NULL DEFAULT 'exclusive'
243                 CHECK (mode IN ('exclusive', 'shared')),
244  holder       text,
245  scope        text,
246  repo         text,
247  path         text,
248  branch       text,
249  base_sha     text,
250  fence_token  numeric(20,0)
251                 CHECK (fence_token >= 0 AND fence_token <= 18446744073709551615),
252  heartbeat_at timestamptz,
253  expires_at   timestamptz,
254  dirty        boolean NOT NULL DEFAULT false,
255  unpushed     boolean NOT NULL DEFAULT false,
256  disposition  text,
257  created_at   timestamptz NOT NULL DEFAULT now(),
258  updated_at   timestamptz NOT NULL DEFAULT now()
259);
260
261-- gwk.lease carries the fence token — the deposed-writer defense: a lease taken
262-- over hands the new holder a strictly higher fence, and any write stamped with
263-- a stale (lower) fence must be rejected downstream. A fence that could rewind
264-- would re-arm a deposed writer, so it is monotonic non-decreasing here and,
265-- once set, never cleared; and,
266-- as on the FSM tables, every UPDATE is a versioned CAS: version advances by
267-- exactly 1. This binds ALL lease writes — a heartbeat touch or a time-driven
268-- expiry write bumps version too. It is a write-discipline invariant (optimistic
269-- concurrency), NOT a state-transition rule, so it does not contradict the next
270-- line: the lease is NOT one of the four public FSMs (fsm.rs: expiry is
271-- time-driven, release holder-driven), so it gets no edge guard, and which
272-- held/released/expired edges are legal — expiry vs release semantics — is a
273-- backend concern deliberately not modeled here. This contract enforces exactly
274-- two things on the lease: fence monotonicity (above) and the version CAS.
275CREATE FUNCTION gwk.assert_lease_guard() RETURNS trigger
276LANGUAGE plpgsql AS $$
277BEGIN
278  IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
279    RAISE EXCEPTION 'lease version must advance by exactly 1 (got % -> %)',
280      OLD.version, NEW.version;
281  END IF;
282  -- A fence, once set, is monotonic non-decreasing AND may never be cleared.
283  -- Guarding only `NEW < OLD` left a two-step launder: 100 -> NULL (NEW null,
284  -- skips the check) then NULL -> 50 (OLD null, skips it) rewinds the fence and
285  -- re-arms a deposed writer. Refuse clearing or lowering a set fence; a fresh
286  -- fence (OLD null -> NEW value) is still allowed.
287  IF OLD.fence_token IS NOT NULL
288     AND (NEW.fence_token IS NULL OR NEW.fence_token < OLD.fence_token) THEN
289    RAISE EXCEPTION 'lease fence_token must not decrease or be cleared (got % -> %)',
290      OLD.fence_token, NEW.fence_token;
291  END IF;
292  RETURN NEW;
293END $$;
294
295CREATE TRIGGER lease_guard
296  BEFORE UPDATE ON gwk.lease
297  FOR EACH ROW EXECUTE FUNCTION gwk.assert_lease_guard();
298
299ALTER TABLE gwk.lease ENABLE ALWAYS TRIGGER lease_guard;
300
301CREATE TABLE gwk.task (
302  id          text PRIMARY KEY,
303  version     bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
304  state       text NOT NULL DEFAULT 'submitted'
305                CHECK (state IN ('submitted', 'working', 'input_required',
306                                 'completed', 'failed', 'canceled')),
307  kind        text,
308  title       text,
309  spec_ref    text,
310  project     text,
311  priority    integer,
312  tracker_ref text,
313  created_at  timestamptz NOT NULL DEFAULT now(),
314  updated_at  timestamptz NOT NULL DEFAULT now()
315);
316
317CREATE TRIGGER task_transition
318  BEFORE UPDATE ON gwk.task
319  FOR EACH ROW EXECUTE FUNCTION gwk.assert_transition('task');
320
321CREATE TRIGGER task_born_initial
322  BEFORE INSERT ON gwk.task
323  FOR EACH ROW EXECUTE FUNCTION gwk.assert_initial_state('task', 'submitted');
324
325CREATE TRIGGER task_no_delete
326  BEFORE DELETE ON gwk.task
327  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('task');
328
329-- Row-level triggers never fire on TRUNCATE, so the delete guard needs a
330-- statement-level partner — else TRUNCATE wipes the table and every id is free
331-- to be re-inserted at the initial state, the exact DELETE-then-INSERT
332-- walk-around task_no_delete exists to stop, in one statement.
333CREATE TRIGGER task_no_truncate
334  BEFORE TRUNCATE ON gwk.task
335  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_state_row_delete('task');
336
337ALTER TABLE gwk.task ENABLE ALWAYS TRIGGER task_transition;
338ALTER TABLE gwk.task ENABLE ALWAYS TRIGGER task_born_initial;
339ALTER TABLE gwk.task ENABLE ALWAYS TRIGGER task_no_delete;
340ALTER TABLE gwk.task ENABLE ALWAYS TRIGGER task_no_truncate;
341
342CREATE TABLE gwk.attempt (
343  id                      text PRIMARY KEY,
344  version                 bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
345  state                   text NOT NULL DEFAULT 'queued'
346                            CHECK (state IN ('queued', 'leased', 'starting',
347                                             'running', 'blocked', 'canceling',
348                                             'canceled', 'failed', 'unknown',
349                                             'succeeded')),
350  task_id                 text NOT NULL REFERENCES gwk.task(id),
351  engine                  text NOT NULL,
352  capability              text,
353  role                    text,
354  model_lane              text,
355  permission_profile      text,
356  worktree_lease_id       text REFERENCES gwk.lease(id),
357  base_sha                text,
358  budget                  jsonb,
359  result_schema_ref       text,
360  provider_session_ref    text,
361  runtime_ref             text,
362  runtime_started_at      timestamptz,
363  exit_code               integer,
364  provider_terminal_event text,
365  result_valid            boolean,
366  evidence_manifest_ref   text,
367  gate_result             text,
368  created_at              timestamptz NOT NULL DEFAULT now(),
369  updated_at              timestamptz NOT NULL DEFAULT now()
370);
371
372CREATE TRIGGER attempt_transition
373  BEFORE UPDATE ON gwk.attempt
374  FOR EACH ROW EXECUTE FUNCTION gwk.assert_transition('attempt');
375
376CREATE TRIGGER attempt_born_initial
377  BEFORE INSERT ON gwk.attempt
378  FOR EACH ROW EXECUTE FUNCTION gwk.assert_initial_state('attempt', 'queued');
379
380CREATE TRIGGER attempt_no_delete
381  BEFORE DELETE ON gwk.attempt
382  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('attempt');
383
384CREATE TRIGGER attempt_no_truncate
385  BEFORE TRUNCATE ON gwk.attempt
386  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_state_row_delete('attempt');
387
388ALTER TABLE gwk.attempt ENABLE ALWAYS TRIGGER attempt_transition;
389ALTER TABLE gwk.attempt ENABLE ALWAYS TRIGGER attempt_born_initial;
390ALTER TABLE gwk.attempt ENABLE ALWAYS TRIGGER attempt_no_delete;
391ALTER TABLE gwk.attempt ENABLE ALWAYS TRIGGER attempt_no_truncate;
392
393CREATE TABLE gwk.engine_session (
394  id                   text PRIMARY KEY,
395  attempt_id           text NOT NULL REFERENCES gwk.attempt(id),
396  engine               text NOT NULL,
397  provider_session_ref text,
398  started_at           timestamptz NOT NULL,
399  ended_at             timestamptz
400);
401
402CREATE TABLE gwk.message (
403  id                 text PRIMARY KEY,
404  version            bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
405  state              text NOT NULL DEFAULT 'accepted'
406                       CHECK (state IN ('accepted', 'delivered', 'acknowledged',
407                                        'applied', 'rejected', 'dead_letter')),
408  idempotency_key    text NOT NULL UNIQUE,
409  correlation_id     text,
410  reply_to           text REFERENCES gwk.message(id),
411  sender             text,
412  recipient          text,
413  channel            text,
414  kind               text,
415  payload            jsonb,
416  deadline           timestamptz,
417  delivery_attempts  integer NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0),
418  dead_letter_reason text,
419  -- channel name -> opaque per-channel delivery reference
420  delivery_refs      jsonb,
421  created_at         timestamptz NOT NULL DEFAULT now(),
422  updated_at         timestamptz NOT NULL DEFAULT now()
423);
424
425CREATE TRIGGER message_transition
426  BEFORE UPDATE ON gwk.message
427  FOR EACH ROW EXECUTE FUNCTION gwk.assert_transition('message');
428
429CREATE TRIGGER message_born_initial
430  BEFORE INSERT ON gwk.message
431  FOR EACH ROW EXECUTE FUNCTION gwk.assert_initial_state('message', 'accepted');
432
433CREATE TRIGGER message_no_delete
434  BEFORE DELETE ON gwk.message
435  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('message');
436
437CREATE TRIGGER message_no_truncate
438  BEFORE TRUNCATE ON gwk.message
439  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_state_row_delete('message');
440
441ALTER TABLE gwk.message ENABLE ALWAYS TRIGGER message_transition;
442ALTER TABLE gwk.message ENABLE ALWAYS TRIGGER message_born_initial;
443ALTER TABLE gwk.message ENABLE ALWAYS TRIGGER message_no_delete;
444ALTER TABLE gwk.message ENABLE ALWAYS TRIGGER message_no_truncate;
445
446CREATE TABLE gwk.command (
447  id              text PRIMARY KEY,
448  version         bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
449  state           text NOT NULL DEFAULT 'issued'
450                    CHECK (state IN ('issued', 'targeted', 'signaled',
451                                     'verification_complete')),
452  kind            text NOT NULL,
453  target          text,
454  actor           jsonb,
455  idempotency_key text UNIQUE,
456  -- The verified RESULT, distinct from the progress spine: present exactly
457  -- when the command is verification_complete, written in the same
458  -- transaction as that terminal transition — this CHECK is what makes a
459  -- split write unrepresentable.
460  outcome         text CHECK (outcome IN ('clean', 'partial', 'unknown')),
461  created_at      timestamptz NOT NULL DEFAULT now(),
462  updated_at      timestamptz NOT NULL DEFAULT now(),
463  CONSTRAINT outcome_iff_verification_complete
464    CHECK ((state = 'verification_complete') = (outcome IS NOT NULL))
465);
466
467CREATE TRIGGER command_transition
468  BEFORE UPDATE ON gwk.command
469  FOR EACH ROW EXECUTE FUNCTION gwk.assert_transition('command');
470
471CREATE TRIGGER command_born_initial
472  BEFORE INSERT ON gwk.command
473  FOR EACH ROW EXECUTE FUNCTION gwk.assert_initial_state('command', 'issued');
474
475CREATE TRIGGER command_no_delete
476  BEFORE DELETE ON gwk.command
477  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('command');
478
479CREATE TRIGGER command_no_truncate
480  BEFORE TRUNCATE ON gwk.command
481  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_state_row_delete('command');
482
483ALTER TABLE gwk.command ENABLE ALWAYS TRIGGER command_transition;
484ALTER TABLE gwk.command ENABLE ALWAYS TRIGGER command_born_initial;
485ALTER TABLE gwk.command ENABLE ALWAYS TRIGGER command_no_delete;
486ALTER TABLE gwk.command ENABLE ALWAYS TRIGGER command_no_truncate;
487
488CREATE TABLE gwk.gate (
489  id           text PRIMARY KEY,
490  version      bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
491  attempt_id   text REFERENCES gwk.attempt(id),
492  phase_ref    text,
493  -- OPEN kind (verify/review/security/eval/cert/...): new gate kinds are
494  -- additive, never a schema change
495  kind         text,
496  verdict      text NOT NULL DEFAULT 'pending'
497                 CHECK (verdict IN ('pending', 'pass', 'fail', 'partial')),
498  evidence_ref text,
499  created_at   timestamptz NOT NULL DEFAULT now(),
500  updated_at   timestamptz NOT NULL DEFAULT now()
501);
502
503CREATE TABLE gwk.authority_grant (
504  id           text PRIMARY KEY,
505  grantee      jsonb NOT NULL,
506  action_class text NOT NULL,
507  scope        text,
508  granted_at   timestamptz NOT NULL DEFAULT now(),
509  expires_at   timestamptz,
510  -- Set together when the grant is withdrawn. Without them a revoke could only
511  -- be expressed by backdating `expires_at`, which makes "revoked for cause"
512  -- and "expired on schedule" the same row — and `gw authority list` reads this
513  -- table as the audit trail, so that difference is the point of it.
514  revoked_at    timestamptz,
515  revoke_reason text,
516  receipt_id   text
517);
518
519-- Append-only attestation ledger (no version/updated_at: receipts are facts).
520CREATE TABLE gwk.receipt (
521  id             text PRIMARY KEY,
522  actor          jsonb NOT NULL,
523  action         text NOT NULL,
524  subject_type   text NOT NULL,
525  subject_id     text NOT NULL,
526  from_state     text,
527  to_state       text,
528  observed_basis text,
529  ts             timestamptz NOT NULL DEFAULT now()
530);
531
532CREATE FUNCTION gwk.forbid_receipt_mutation() RETURNS trigger
533LANGUAGE plpgsql AS $$
534BEGIN
535  RAISE EXCEPTION 'gwk.receipt is append-only (% refused)', TG_OP;
536END $$;
537
538CREATE TRIGGER receipt_append_only
539  BEFORE UPDATE OR DELETE ON gwk.receipt
540  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_receipt_mutation();
541
542-- Statement-level TRUNCATE cover, as on gwk.event.
543CREATE TRIGGER receipt_no_truncate
544  BEFORE TRUNCATE ON gwk.receipt
545  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_receipt_mutation();
546
547ALTER TABLE gwk.receipt ENABLE ALWAYS TRIGGER receipt_append_only;
548ALTER TABLE gwk.receipt ENABLE ALWAYS TRIGGER receipt_no_truncate;
549
550CREATE TABLE gwk.evidence (
551  id         text PRIMARY KEY,
552  -- OPEN kind (transcript/diff/log/...); no format column
553  kind       text NOT NULL,
554  ref        text NOT NULL,
555  digest     text,
556  byte_size  numeric(20,0)
557               CHECK (byte_size >= 0 AND byte_size <= 18446744073709551615),
558  created_at timestamptz NOT NULL DEFAULT now()
559);
560
561CREATE TABLE gwk.attention_item (
562  id          text PRIMARY KEY,
563  kind        text NOT NULL,
564  summary     text NOT NULL,
565  subject_ref text,
566  raised_by   jsonb,
567  raised_at   timestamptz NOT NULL DEFAULT now(),
568  resolved_at timestamptz,
569  resolution  text
570);
571
572-- Attention deduplicates by (kind, subject_ref) WHILE UNRESOLVED: a page fires
573-- once per real problem, not once per retry of the command that hit it. The
574-- predicate is what makes the same pair raisable again after someone resolves
575-- it — a recurring problem is a new item, not a reopened one.
576--
577-- NULL `subject_ref` stays distinct (the PostgreSQL default): an item about
578-- nothing in particular is not the same item as another about nothing in
579-- particular. The kernel's own page path always names a subject, so this only
580-- affects an explicit RaiseAttention that omits one.
581CREATE UNIQUE INDEX attention_item_unresolved_dedup
582  ON gwk.attention_item (kind, subject_ref)
583  WHERE resolved_at IS NULL;
584
585-- The only projection a replay cannot rebuild. `gwk.receipt` is the other one,
586-- and it is append-only; this table is not, because resolving an item is an
587-- UPDATE. But the kernel's own page path writes the row directly inside the
588-- refused command's transaction with no event behind it, so a deleted item is
589-- gone from every copy at once — and `derived_records`, which is what a
590-- checkpoint and a scratch rebuild hash, deliberately leaves this table out.
591-- Nothing else would notice. The refusal's receipt survives either way, so what
592-- these guards protect is the operator's queue rather than the audit trail.
593CREATE TRIGGER attention_item_no_delete
594  BEFORE DELETE ON gwk.attention_item
595  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('attention_item');
596
597CREATE TRIGGER attention_item_no_truncate
598  BEFORE TRUNCATE ON gwk.attention_item
599  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_state_row_delete('attention_item');
600
601ALTER TABLE gwk.attention_item ENABLE ALWAYS TRIGGER attention_item_no_delete;
602ALTER TABLE gwk.attention_item ENABLE ALWAYS TRIGGER attention_item_no_truncate;
603
604-- `released_at`/`disposition` are what make a released worktree distinguishable
605-- from a live one in the projection. Without them the release is recoverable
606-- only by replaying the log, and `projection get worktree <id>` — a first-class
607-- read in the CLI surface — could not answer "is anyone still in this tree?".
608-- The lease beside it carries the same pair for the same reason.
609CREATE TABLE gwk.worktree (
610  id          text PRIMARY KEY,
611  repo        text NOT NULL,
612  path        text NOT NULL,
613  branch      text NOT NULL,
614  base_sha    text,
615  lease_id    text REFERENCES gwk.lease(id),
616  dirty       boolean NOT NULL DEFAULT false,
617  unpushed    boolean NOT NULL DEFAULT false,
618  released_at timestamptz,
619  disposition text,
620  created_at  timestamptz NOT NULL DEFAULT now()
621);
622
623-- A dispatch node is bookkeeping over the spawn tree, NOT one of the four
624-- public state machines: `state` is an OPEN bounded lifecycle label with no
625-- seeded edge set, so `assert_transition` (which trusts gwk.transition) does
626-- not apply. What DOES apply is the CAS discipline every versioned row owes,
627-- because concurrent writers race the same node.
628CREATE TABLE gwk.dispatch_node (
629  id         text PRIMARY KEY,
630  version    bigint NOT NULL DEFAULT 1 CHECK (version BETWEEN 1 AND 4294967295),
631  parent_id  text REFERENCES gwk.dispatch_node(id),
632  attempt_id text REFERENCES gwk.attempt(id),
633  kind       text NOT NULL,
634  state      text NOT NULL DEFAULT 'registered',
635  label      text,
636  created_at timestamptz NOT NULL DEFAULT now(),
637  updated_at timestamptz NOT NULL DEFAULT now()
638);
639
640-- Version CAS without edge legality — the guard an open-lifecycle versioned
641-- table needs. Deliberately NOT gwk.assert_transition: that one refuses any
642-- state change lacking a gwk.transition row, and dispatch_node has no seeded
643-- edges by design, so every transition would be refused.
644CREATE FUNCTION gwk.assert_version_cas() RETURNS trigger
645LANGUAGE plpgsql AS $$
646BEGIN
647  IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
648    RAISE EXCEPTION '% version must advance by exactly 1 (got % -> %)',
649      TG_ARGV[0], OLD.version, NEW.version;
650  END IF;
651  RETURN NEW;
652END $$;
653
654CREATE TRIGGER dispatch_node_cas
655  BEFORE UPDATE ON gwk.dispatch_node
656  FOR EACH ROW EXECUTE FUNCTION gwk.assert_version_cas('dispatch_node');
657CREATE TRIGGER dispatch_node_no_delete
658  BEFORE DELETE ON gwk.dispatch_node
659  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_state_row_delete('dispatch_node');
660
661ALTER TABLE gwk.dispatch_node ENABLE ALWAYS TRIGGER dispatch_node_cas;
662ALTER TABLE gwk.dispatch_node ENABLE ALWAYS TRIGGER dispatch_node_no_delete;
663
664-- The orchestrator's crash-recovery snapshot, latest-per-orchestrator.
665--
666-- Distinct from `gwk-domain::checkpoint::Checkpoint`, which is the kernel's own
667-- projection-hash checkpoint: this one is the orchestrator's impression of its
668-- own in-flight work at crash time. Recovery re-reads the live rows; this is
669-- the crash-time impression, not truth.
670--
671-- `seq` is the monotonic per-orchestrator counter and is guarded below. It is a
672-- resume cursor, so a rewind would let recovery restart from state that has
673-- already been superseded.
674CREATE TABLE gwk.orchestrator_checkpoint (
675  orchestrator_id    text PRIMARY KEY,
676  seq                numeric(20,0) NOT NULL
677                       CHECK (seq >= 0 AND seq <= 18446744073709551615),
678  native_session_ref text,
679  active_goal        text,
680  active_step_ref    text,
681  latest_command_ref text,
682  open_attempts      jsonb,
683  leases             jsonb,
684  pending_approvals  jsonb,
685  budget_cursor      jsonb,
686  updated_at         timestamptz NOT NULL DEFAULT now()
687);
688
689CREATE FUNCTION gwk.assert_checkpoint_seq_advances() RETURNS trigger
690LANGUAGE plpgsql AS $$
691BEGIN
692  IF NEW.seq <= OLD.seq THEN
693    RAISE EXCEPTION 'orchestrator_checkpoint seq must advance (got % -> %)',
694      OLD.seq, NEW.seq;
695  END IF;
696  RETURN NEW;
697END $$;
698
699CREATE TRIGGER orchestrator_checkpoint_seq
700  BEFORE UPDATE ON gwk.orchestrator_checkpoint
701  FOR EACH ROW EXECUTE FUNCTION gwk.assert_checkpoint_seq_advances();
702
703ALTER TABLE gwk.orchestrator_checkpoint ENABLE ALWAYS TRIGGER orchestrator_checkpoint_seq;
704
705-- Records that entered the log through ingestion rather than through an
706-- aggregate's lifecycle: memory, knowledge, graph snapshots, eval verdicts,
707-- insights, cost, health, session, config, checkpoints, rounds, findings.
708--
709-- `kind` is the one CLOSED classification column in this file. Everywhere else
710-- an open bounded string is preferred so a new label is additive; here the
711-- closed set IS the property — the absence of an import path is load-bearing,
712-- and an open column would let `import` in as data. The CHECK
713-- lists the twelve accepted kinds so the refusal happens in the database and
714-- not only in the process that wrote the row.
715--
716-- No version, no state, no updated_at: an ingested record is a fact that
717-- arrived, like gwk.receipt. `event_seq` points back at the log entry that
718-- produced it, which is the only thing that can prove the row — ingestion
719-- writes no gwk.transition.
720CREATE TABLE gwk.ingested_record (
721  id          text PRIMARY KEY,
722  kind        text NOT NULL
723                CHECK (kind IN ('memory', 'knowledge', 'graph_snapshot',
724                                'eval_verdict', 'insight', 'cost', 'health',
725                                'session', 'config', 'checkpoint', 'round',
726                                'finding')),
727  -- Bounded inline content, or a descriptor of the blob beside it. The 64 KiB
728  -- bound belongs to the append path (it bounds the whole event payload); a
729  -- column CHECK here would be a second, drifting copy of it.
730  payload     jsonb NOT NULL,
731  payload_ref jsonb,
732  ingested_by jsonb,
733  event_seq   numeric(20,0) NOT NULL
734                CHECK (event_seq >= 0 AND event_seq <= 18446744073709551615),
735  ingested_at timestamptz NOT NULL DEFAULT now()
736);
737
738CREATE FUNCTION gwk.forbid_ingested_record_mutation() RETURNS trigger
739LANGUAGE plpgsql AS $$
740BEGIN
741  RAISE EXCEPTION 'gwk.ingested_record is append-only (% refused)', TG_OP;
742END $$;
743
744CREATE TRIGGER ingested_record_append_only
745  BEFORE UPDATE OR DELETE ON gwk.ingested_record
746  FOR EACH ROW EXECUTE FUNCTION gwk.forbid_ingested_record_mutation();
747
748-- Statement-level TRUNCATE cover, as on gwk.event and gwk.receipt.
749CREATE TRIGGER ingested_record_no_truncate
750  BEFORE TRUNCATE ON gwk.ingested_record
751  FOR EACH STATEMENT EXECUTE FUNCTION gwk.forbid_ingested_record_mutation();
752
753ALTER TABLE gwk.ingested_record ENABLE ALWAYS TRIGGER ingested_record_append_only;
754ALTER TABLE gwk.ingested_record ENABLE ALWAYS TRIGGER ingested_record_no_truncate;
755
756COMMIT;
757"#;
758
759/// SHA-256 of [`CONTRACT_SQL`], lowercase hex. Initialization records this
760/// in `gwk_internal.schema_fingerprint`, so a later boot can tell "this
761/// database carries MY contract" from "this database carries another".
762// Pre-wrapped: `cargo fmt --all` formats generated files too, and an
763// unwrapped 64-hex line lands past 100 columns — the generator and
764// rustfmt would then fight, showing up as permanent contract drift.
765pub const CONTRACT_SQL_SHA256: &str =
766    "158da24f2d5b24dd82128db463512b5a7672f1849817779fdea4a11676e5ec30";