-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.sql
More file actions
1430 lines (1248 loc) · 66.4 KB
/
Copy pathschema.sql
File metadata and controls
1430 lines (1248 loc) · 66.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- AgentDrive v0 — the day-0 schema.
--
-- Nine tables, derived from the 39 operations of the ratified v0 contract
-- rather than pruned from the 52-table legacy baseline. Written on a blank
-- file deliberately: the legacy schema had `path` NOT NULL and `parent_id`
-- nullable, because the v0 tree was added *beside* the path-addressed
-- router. A pruned file would very plausibly have kept that inversion, and
-- it would have looked deliberate.
--
-- This file is the COMPLETE shape of a fresh database. There is no migration
-- chain behind it: the v0 chain starts at 0001 against this baseline.
-- `agentdrive.scripts.apply_schema` applies it in one transaction.
--
-- Contract: docs/superpowers/specs/2026-07-30-agentdrive-v0-api-contract-design.md
-- Section references below are to that document. §12A names, for every
-- invariant, the layer that enforces it and the artifact that proves it;
-- every constraint here is one of those artifacts.
--
-- Ownership (§3.1). Hub owns principals, workspaces, memberships, product
-- entitlement, OAuth credentials, and token issuance. AgentDrive owns
-- drives, namespaces, versions and bytes, local grants and shares, search
-- and usage, and the change feed. Hub ids appear here only as opaque
-- references (`tcagt_*`, `tcusr_*`, a workspace id) — never as a local copy
-- of a Hub row, because two planes holding the same fact is how they come
-- to disagree.
-- ---------------------------------------------------------------------------
-- drives — the storage and authorization boundary (§4, §6.1)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY
CHECK (id ~ '^drv_[a-f0-9]{16}$'),
-- The Hub workspace this drive belongs to. An opaque reference: AgentDrive
-- never reads workspace membership from a local table, it intersects the
-- token's scope with a local grant (§3.1, §7.1).
workspace_id TEXT NOT NULL,
-- Immutable server-observed attribution (§4.1): the authenticated principal
-- that created the drive, never a client-supplied claim. Stored once at
-- creation and never mutated — the durable "who created this drive"
-- record. The creator is ALSO the first drive-level `manager` grant
-- (grants row), but this column answers the question without a join and
-- survives any later grant changes (revocation/rotation) unchanged.
created_by_principal_id TEXT,
name TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Every user-visible mutation produces a new revision, and `ETag` is the
-- quoted current revision (§4.1). Not a transaction id, timestamp, or GCS
-- generation — those leak server state into a client-visible identifier.
revision TEXT NOT NULL
CHECK (revision ~ '^rev_[a-f0-9]{16}$'),
-- A drive exposes one root-folder id (§4.2). The FK is added after
-- `folders` exists and is DEFERRABLE, so a drive and its root folder are
-- creatable in one transaction.
root_folder_id TEXT,
-- §6.1 `GET /drives/{id}/usage`. Non-negative CHECKs per §12A: a negative
-- counter is unreachable by correct code, which is exactly why it wants a
-- constraint — it is the signature of a double-decrement, and without this
-- it surfaces as a wrong number rather than an error.
storage_bytes BIGINT NOT NULL DEFAULT 0 CHECK (storage_bytes >= 0),
retrieval_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retrieval_bytes >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS drives_by_workspace ON drives (workspace_id) WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- folders — mutable tree nodes (§4.2, §6.2)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY
CHECK (id ~ '^fld_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
-- NULL only for the drive's structural root, pinned to exactly one row per
-- drive by `folders_one_root` below. Every other folder has one parent
-- (§4.2); paths are derived from this chain and never stored.
parent_id TEXT,
-- A single path segment, never a mutable full path (§4.2). NULL exactly
-- when this is the root, which has no segment to name.
name TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
revision TEXT NOT NULL
CHECK (revision ~ '^rev_[a-f0-9]{16}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
-- Recursive-deletion cohort (§6.2 restore semantics): every row a
-- recursive folder delete removes is stamped with ONE shared cohort id, so
-- a restore resurrects exactly that deletion cohort and never sweeps up
-- rows deleted earlier for unrelated reasons. NULL on a deleted row = the
-- deletion predates cohort tracking and is not restorable.
deleted_cohort_id TEXT,
CONSTRAINT folders_root_has_no_name
CHECK ((parent_id IS NULL) = (name IS NULL)),
-- Referenceable as a composite so children can be pinned to one drive.
CONSTRAINT folders_drive_id_key UNIQUE (drive_id, id)
);
-- §4: a parent reference cannot cross a drive boundary. A plain FK on
-- `parent_id` alone would let a folder in drive A parent a node in drive B —
-- a state no operation can undo, because every repair path is itself
-- drive-scoped. Carrying `drive_id` into the FK makes it unrepresentable.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'folders_parent_same_drive') THEN
ALTER TABLE folders
ADD CONSTRAINT folders_parent_same_drive
FOREIGN KEY (drive_id, parent_id) REFERENCES folders (drive_id, id)
ON DELETE RESTRICT;
END IF;
END $$;
-- Composite, for the same reason `folders_parent_same_drive` is: §12A puts
-- "parent AND ROOT references stay inside one drive" at the DB-constraint
-- layer, and a bare FK on `root_folder_id` alone let a drive root itself at a
-- folder belonging to another drive. No drive-scoped operation can repair
-- that, because every repair path is itself drive-scoped.
--
-- MATCH SIMPLE short-circuits when `root_folder_id` IS NULL, so the
-- create-drive-then-create-root flow still works, and DEFERRABLE still lets
-- both rows land in one transaction.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'drives_root_folder_fk') THEN
ALTER TABLE drives
ADD CONSTRAINT drives_root_folder_fk
FOREIGN KEY (id, root_folder_id) REFERENCES folders (drive_id, id)
DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;
-- Replay-path backfill: a non-fresh database created `drives` before the
-- `created_by_principal_id` column existed. The table definition above does
-- not add it to an existing table, so this idempotent ADD closes the gap.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'drives' AND column_name = 'created_by_principal_id'
) THEN
ALTER TABLE drives ADD COLUMN created_by_principal_id TEXT;
END IF;
END $$;
-- §6.2: the structural root is the only parent-less folder in a drive.
CREATE UNIQUE INDEX IF NOT EXISTS folders_one_root ON folders (drive_id) WHERE parent_id IS NULL;
-- §6.2: sibling artifact and folder names share ONE collision domain.
--
-- Postgres has no cross-table unique index, so this is enforced in two
-- pieces, and it is worth being exact about which piece holds what because
-- §12A assigns the whole invariant to the DB-constraint layer:
--
-- * WITHIN a table, the partial unique index below is a real DB constraint
-- and cannot be bypassed.
-- * ACROSS the two tables, `reject_cross_kind_name_collision` (defined
-- after `artifacts`) is a trigger — §12A's layer 2. It rejects every
-- sequential violation from any writer, including writers nobody has
-- written yet, which is strictly more than the comment here used to
-- claim while nothing enforced it at all.
--
-- What the trigger does NOT close is the concurrent case: under READ
-- COMMITTED two transactions inserting the same name, one per table, cannot
-- see each other's uncommitted row, so both pass and both commit. Closing
-- that needs the Layer 5 mutation transaction to serialize on the parent
-- (an advisory lock keyed on parent_id), which is where the contract's
-- §12A "app transaction" fallback genuinely applies.
--
-- Recorded rather than glossed: this is a partial demotion from §12A's
-- stated layer, and §12A requires such a move to be deliberate.
CREATE UNIQUE INDEX IF NOT EXISTS folders_namespace ON folders (parent_id, name)
WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- Search helpers
-- ---------------------------------------------------------------------------
-- Joins a label array so `to_tsvector` can stem it.
--
-- This exists only to satisfy a volatility constraint. A generated column's
-- expression must be IMMUTABLE, and `array_to_string` is merely STABLE — it
-- calls the element type's output function, which for some types (dates,
-- floats) varies with session settings like DateStyle. Postgres marks it
-- conservatively for ALL types rather than per-type.
--
-- Declaring IMMUTABLE over a STABLE call is normally how you corrupt an
-- index. It is sound HERE, and only here, because the argument is pinned to
-- `text[]`: text's output function is the identity and reads no session
-- state, so the result genuinely depends on nothing but the input.
--
-- Two rules follow, and both matter:
-- * Do not widen the signature to `anyarray`. That reintroduces exactly
-- the type-dependent output the pin rules out.
-- * Do not CREATE OR REPLACE this with different behavior. Stored
-- generated values are NOT recomputed on redefinition, so old rows
-- would keep the old tokenization while new rows got the new one — a
-- silently half-migrated index. Changing it means dropping and
-- re-adding `artifacts.search_tsv`, which rewrites the table.
CREATE OR REPLACE FUNCTION labels_text(labels text[])
RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
STRICT
AS $$ SELECT array_to_string(labels, ' ') $$;
-- ---------------------------------------------------------------------------
-- artifacts — mutable identities with a head version (§4, §6.3)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS artifacts (
id TEXT PRIMARY KEY
CHECK (id ~ '^art_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
-- Mandatory, unlike folders: an artifact is never the root of anything.
-- The legacy schema had this nullable and `path` NOT NULL, which is the
-- inversion this file exists to correct.
parent_id TEXT NOT NULL,
name TEXT NOT NULL,
-- Denormalized from the head version for listing and filtering (§6.11).
-- Authoritative content facts live on the version row.
content_type TEXT,
content_preview TEXT,
labels TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Set after the first version exists; the composite FK below pins it to a
-- version of THIS artifact.
head_version_id TEXT,
revision TEXT NOT NULL
CHECK (revision ~ '^rev_[a-f0-9]{16}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
-- Recursive-deletion cohort: see the note on `folders.deleted_cohort_id`.
-- A recursive folder delete stamps its artifacts with the SAME cohort id as
-- the folders it removes; an individual artifact soft-delete stamps its own
-- cohort of one. Restore clears both `deleted_at` and the cohort.
deleted_cohort_id TEXT,
-- §6.6 and blocker B3. A trigger- or handler-maintained tsvector goes
-- stale the first time a writer forgets, and the legacy surface proved
-- that happens. GENERATED cannot: Postgres recomputes it on every write,
-- including from a code path nobody has written yet. The cost is that only
-- same-row columns are visible — which is why ancestor path is not indexed
-- here, and why search is name/preview/metadata/label scoped by design
-- rather than by omission.
--
-- Every arm must be IMMUTABLE or Postgres rejects the column outright,
-- which is why the label arm goes through `labels_text` — see the note on
-- that function. Labels are stemmed like everything else, so a search for
-- `quarter` finds an artifact labelled `quarterly`; exact tokens would
-- make a label findable only by typing it in full.
--
-- B3 (finding 2 in schema-integrity): the values feeding these arms are
-- unbounded (name/preview/labels carry no length CHECK; metadata is
-- unbounded JSONB, and the v0 inline-body ceiling is 20 MiB), but a
-- tsvector is capped at 1 MiB. Left unbounded, a legal-sized row makes
-- every INSERT/UPDATE fail at the storage layer with
-- "string is too long for tsvector". Each arm therefore truncates its
-- input with `left(...)`. The caps are generous — a search index that
-- covers the first N bytes of an oversized document is far more useful
-- than a write that hard-fails — while their total (≈180 KB exercise
-- worst case, well under the 1 MiB limit even after lexeme/position
-- expansion) keeps the generated output from ever tripping the guard.
-- Indexing metadata as text (not jsonb_to_tsvector) is deliberate:
-- `left(metadata::text, N)::jsonb` could truncate mid-token into invalid
-- JSON and re-introduce the very write-blocker being removed.
search_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english'::regconfig,
left(regexp_replace(name, '[._-]+', ' ', 'g'), 4096)), 'A')
|| setweight(to_tsvector('english'::regconfig,
left(coalesce(content_preview, ''), 65536)), 'B')
|| setweight(to_tsvector('english'::regconfig,
left(coalesce(metadata::text, '{}'), 98304)), 'C')
|| setweight(to_tsvector('english'::regconfig,
left(labels_text(labels), 16384)), 'D')
) STORED,
CONSTRAINT artifacts_drive_id_key UNIQUE (drive_id, id)
);
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'artifacts_parent_same_drive') THEN
ALTER TABLE artifacts
ADD CONSTRAINT artifacts_parent_same_drive
FOREIGN KEY (drive_id, parent_id) REFERENCES folders (drive_id, id)
ON DELETE RESTRICT;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS artifacts_namespace ON artifacts (parent_id, name)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS artifacts_search ON artifacts USING GIN (search_tsv);
CREATE INDEX IF NOT EXISTS artifacts_by_parent ON artifacts (parent_id, id)
WHERE deleted_at IS NULL;
-- §6.2 across the two tables. See the note on `folders_namespace` for what
-- this does and does not guarantee.
--
-- `pg_trigger_depth() > 1` is not used: this trigger writes nothing, so it
-- cannot recurse. The lookup is a single indexed probe on the other table's
-- namespace index, on a path that is already doing a unique-index insert.
CREATE OR REPLACE FUNCTION reject_cross_kind_name_collision()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
clash TEXT;
BEGIN
IF NEW.deleted_at IS NOT NULL OR NEW.name IS NULL THEN
RETURN NEW;
END IF;
IF TG_TABLE_NAME = 'folders' THEN
SELECT id INTO clash FROM artifacts
WHERE parent_id = NEW.parent_id AND name = NEW.name AND deleted_at IS NULL
LIMIT 1;
ELSE
SELECT id INTO clash FROM folders
WHERE parent_id = NEW.parent_id AND name = NEW.name AND deleted_at IS NULL
LIMIT 1;
END IF;
IF clash IS NOT NULL THEN
RAISE EXCEPTION
'name % already exists under % (as %)', NEW.name, NEW.parent_id, clash
USING ERRCODE = 'unique_violation';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS folders_namespace_cross_kind ON folders;
CREATE TRIGGER folders_namespace_cross_kind
BEFORE INSERT OR UPDATE OF parent_id, name, deleted_at ON folders
FOR EACH ROW EXECUTE FUNCTION reject_cross_kind_name_collision();
DROP TRIGGER IF EXISTS artifacts_namespace_cross_kind ON artifacts;
CREATE TRIGGER artifacts_namespace_cross_kind
BEFORE INSERT OR UPDATE OF parent_id, name, deleted_at ON artifacts
FOR EACH ROW EXECUTE FUNCTION reject_cross_kind_name_collision();
-- ---------------------------------------------------------------------------
-- artifact_versions — immutable bytes (§4.3, §6.4)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS artifact_versions (
id TEXT PRIMARY KEY
CHECK (id ~ '^ver_[a-f0-9]{16}$'),
artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
-- Stored so linear history can become a DAG later without redefining
-- artifact identity (§4.3). v0 exposes it as linear. The FK is a composite
-- (artifact_id, parent_version_id) constraint added below, not a bare
-- self-reference, so a parent must be a version of the *same* artifact.
parent_version_id TEXT,
checksum TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
-- Object-store key. The bytes themselves never live in Postgres; `GET
-- /content` is normally a 307 to the object store (§6.3).
storage_object TEXT NOT NULL,
-- B3 direct-transfer coordinates (migration 0049): the object's bucket and
-- exact GCS generation. Nullable during reconciliation — the guarded
-- reconcile_generations job resolves legacy CAS rows NULL → observed value;
-- new writes persist both at commit. Never fabricated. The immutability
-- trigger below permits ONLY that one NULL → value transition.
storage_bucket TEXT,
storage_generation BIGINT
CONSTRAINT artifact_versions_generation_positive
CHECK (storage_generation IS NULL OR storage_generation > 0),
-- Coordinates are all-or-none (nonempty bucket): "resolved" is one fact.
-- Where this version came from (migration 0053). Denormalised and
-- FK-free on purpose: `sheet_sessions` rows are swept a day after they
-- terminate, which is exactly when the provenance becomes worth keeping,
-- so the id is opaque and may no longer resolve while the message
-- outlives it. Both are frozen by the append-only trigger below.
origin_session_id TEXT,
origin_message TEXT,
CONSTRAINT artifact_versions_coordinates_all_or_none
CHECK (
((storage_bucket IS NULL) = (storage_generation IS NULL))
AND (storage_bucket IS NULL OR storage_bucket <> '')
),
-- Server-observed attribution (§4). The authenticated principal, never a
-- client-supplied claim, and never a generic worker standing in for the
-- initiator (§6.7).
actor_type TEXT NOT NULL
CHECK (actor_type IN ('agent', 'user', 'service', 'system')),
actor_id TEXT,
-- Informational only. The version id is the stable handle; numeric version
-- route parameters are removed (§6.4).
ordinal INTEGER NOT NULL CHECK (ordinal >= 1),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Lets `artifacts.head_version_id` be pinned to a version of its own row.
CONSTRAINT artifact_versions_artifact_id_key UNIQUE (artifact_id, id),
CONSTRAINT artifact_versions_ordinal_key UNIQUE (artifact_id, ordinal)
);
-- §6.4 via §12A: `head_version_id` belongs to the same artifact. A bare FK on
-- the id alone would let an artifact point its head at another artifact's
-- version, and every read of it would be silently wrong. DEFERRABLE because
-- artifact and first version are created in one transaction, each
-- referencing the other.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'artifacts_head_version_is_own') THEN
ALTER TABLE artifacts
ADD CONSTRAINT artifacts_head_version_is_own
FOREIGN KEY (id, head_version_id)
REFERENCES artifact_versions (artifact_id, id)
DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;
-- §4.3 via §12A: `parent_version_id` belongs to the same artifact. Mirrors
-- `artifacts_head_version_is_own` above — a bare FK on the id alone would let
-- one artifact's version claim another artifact's version as its parent, a
-- cross-artifact history edge that every version-walk would silently follow.
-- ON DELETE SET NULL uses the PG15+ column-list form so only
-- `parent_version_id` is nulled: a bare `SET NULL` would also try to null
-- `artifact_id`, which is NOT NULL and part of the (artifact_id, id) key.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'artifact_versions_parent_is_own') THEN
ALTER TABLE artifact_versions
ADD CONSTRAINT artifact_versions_parent_is_own
FOREIGN KEY (artifact_id, parent_version_id)
REFERENCES artifact_versions (artifact_id, id)
ON DELETE SET NULL (parent_version_id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS artifact_versions_by_artifact
ON artifact_versions (artifact_id, ordinal DESC);
-- B3 transfer readiness (migration 0050): the per-request readiness gate
-- counts unresolved-coordinate rows; this partial index is empty exactly
-- when the deployment is ready, so the uncached predicate stays
-- O(unresolved). Predicate text matches transfer_readiness verbatim.
CREATE INDEX IF NOT EXISTS artifact_versions_unresolved_coordinates
ON artifact_versions (id)
WHERE storage_generation IS NULL OR storage_bucket IS NULL
OR storage_bucket = '';
-- §6.4 via §12A: a version's CONTENT IDENTITY is immutable. A CHECK cannot
-- express "no UPDATE ever" — it only sees the row being written — so this is a
-- trigger, which also binds the table owner. Without it, immutability is a
-- property of the handlers that happen to exist today rather than of the data.
--
-- Scope (deliberately narrow, default-deny in spirit):
-- * This is BEFORE UPDATE only. DELETE is unaffected — retention pruning
-- (the planned 20/200 version cap) still removes whole version rows.
-- * FROZEN content-identity columns — the version `id`, `artifact_id`,
-- `checksum`, `content_type`, `size_bytes`, `storage_object`, `actor_type`,
-- `actor_id`, `ordinal`, and `created_at` — can never change. A reader, the
-- ETag/checksum comparison, and the GC mark-sweep's live-blob set all
-- derive from these; mutating one would silently corrupt them, so any
-- change still RAISEs.
-- * The ONE permitted transition is `parent_version_id` being set to NULL.
-- That column carries `ON DELETE SET NULL`: when pruning deletes an old
-- version, the referential action fires an UPDATE on the surviving child to
-- null its now-dangling parent pointer, and a blanket reject would break
-- that prune. Re-pointing a parent to a DIFFERENT non-NULL version is
-- history rewriting and is still rejected.
CREATE OR REPLACE FUNCTION reject_artifact_version_update()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Frozen content-identity columns: any change is rejected (NULL-safe compare).
IF NEW.id IS DISTINCT FROM OLD.id
OR NEW.artifact_id IS DISTINCT FROM OLD.artifact_id
OR NEW.checksum IS DISTINCT FROM OLD.checksum
OR NEW.content_type IS DISTINCT FROM OLD.content_type
OR NEW.size_bytes IS DISTINCT FROM OLD.size_bytes
OR NEW.storage_object IS DISTINCT FROM OLD.storage_object
OR NEW.actor_type IS DISTINCT FROM OLD.actor_type
OR NEW.actor_id IS DISTINCT FROM OLD.actor_id
OR NEW.ordinal IS DISTINCT FROM OLD.ordinal
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR NEW.origin_session_id IS DISTINCT FROM OLD.origin_session_id
OR NEW.origin_message IS DISTINCT FROM OLD.origin_message
THEN
RAISE EXCEPTION
'artifact_versions is append-only: content identity of version % cannot be updated',
OLD.id
USING ERRCODE = 'restrict_violation';
END IF;
-- parent_version_id: may only be CLEARED to NULL (the ON DELETE SET NULL tail
-- reporting a pruned parent). Re-pointing to a different non-NULL version is
-- rejected.
IF NEW.parent_version_id IS DISTINCT FROM OLD.parent_version_id
AND NEW.parent_version_id IS NOT NULL
THEN
RAISE EXCEPTION
'artifact_versions.parent_version_id may only be cleared to NULL (pruned parent), not repointed: version %',
OLD.id
USING ERRCODE = 'restrict_violation';
END IF;
-- B3 object coordinates (migration 0049): NULL → observed value is the one
-- permitted reconciliation transition — the guarded reconcile_generations
-- job filling a legacy CAS row. A resolved coordinate never changes and
-- never un-resolves.
IF NEW.storage_bucket IS DISTINCT FROM OLD.storage_bucket
AND OLD.storage_bucket IS NOT NULL
THEN
RAISE EXCEPTION
'artifact_versions.storage_bucket is resolved once and immutable: version %',
OLD.id
USING ERRCODE = 'restrict_violation';
END IF;
IF NEW.storage_generation IS DISTINCT FROM OLD.storage_generation
AND OLD.storage_generation IS NOT NULL
THEN
RAISE EXCEPTION
'artifact_versions.storage_generation is resolved once and immutable: version %',
OLD.id
USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS artifact_versions_immutable ON artifact_versions;
CREATE TRIGGER artifact_versions_immutable
BEFORE UPDATE ON artifact_versions
FOR EACH ROW
EXECUTE FUNCTION reject_artifact_version_update();
-- ---------------------------------------------------------------------------
-- grants — local capability, intersected with token scope (§6.8, §7.1)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS grants (
id TEXT PRIMARY KEY
CHECK (id ~ '^grn_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL
CHECK (resource_type IN ('drive', 'folder', 'artifact')),
resource_id TEXT NOT NULL,
-- Explicit, stable references only (§6.8). No email address, display name,
-- or mutable path is a principal — each of those is a value that can be
-- reassigned to a different human, which would silently transfer access.
principal_type TEXT NOT NULL
CHECK (principal_type IN ('agent', 'user', 'service', 'workspace', 'public')),
principal_id TEXT,
role TEXT NOT NULL
CHECK (role IN ('manager', 'editor', 'viewer')),
-- §4.1: every user-visible mutation of mutable state produces a new
-- revision, and `ETag` is the quoted current revision. Grants are mutable
-- (role/expiry changes, revocation), so they carry one — without it,
-- `If-Match`/`412` would be structurally impossible (the ETag would be the
-- immutable id).
revision TEXT NOT NULL
CHECK (revision ~ '^rev_[a-f0-9]{16}$'),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
-- §6.8: `public` is the publication mechanism and carries no id. Every
-- other principal must name one.
CONSTRAINT grants_public_has_no_id
CHECK ((principal_type = 'public') = (principal_id IS NULL)),
-- §6.8 via §12A: public, only with viewer. A public grant above viewer is
-- an anonymous write, so the schema refuses to represent one rather than
-- trusting every future handler to check.
CONSTRAINT grants_public_is_viewer_only
CHECK (principal_type <> 'public' OR role = 'viewer'),
-- Hub id shapes (§6.8). A workspace id is opaque and unprefixed.
CONSTRAINT grants_principal_id_shape CHECK (
(principal_type = 'agent' AND principal_id ~ '^tcagt_')
OR (principal_type = 'user' AND principal_id ~ '^tcusr_')
OR (principal_type = 'service' AND principal_id ~ '^tcsvc_')
OR (principal_type = 'workspace' AND principal_id IS NOT NULL)
OR (principal_type = 'public' AND principal_id IS NULL)
)
);
-- One live grant per (resource, principal); role changes are PATCH, not a
-- second row (§6.8).
CREATE UNIQUE INDEX IF NOT EXISTS grants_one_live_per_principal
ON grants (resource_type, resource_id, principal_type, coalesce(principal_id, ''))
WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS grants_by_resource ON grants (resource_id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS grants_by_drive ON grants (drive_id) WHERE revoked_at IS NULL;
-- `grants_list?resource_type=&resource_id=` — one resource's access list,
-- bounded by the drive first (0045). Partial like its siblings: the default
-- `state=active` never wants revoked tombstones in the index.
CREATE INDEX IF NOT EXISTS grants_by_drive_resource
ON grants (drive_id, resource_id) WHERE revoked_at IS NULL;
-- Principal matching for authorization (§8): does a grant's principal
-- describe the acting principal? `agent`/`user`/`service` match the subject
-- exactly; `workspace` covers the acting workspace (human members and agent
-- memberships, but NOT a Service Account, which has no membership);
-- `public` (principal_id NULL) covers anyone. One function so every
-- grant-resolution query (folder ancestry, artifact, drive) uses the same
-- rule and they cannot drift apart.
CREATE OR REPLACE FUNCTION _principal_matches(
actor_type TEXT,
actor_subject TEXT,
actor_workspace TEXT,
principal_type TEXT,
principal_id TEXT
) RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT
(principal_type = actor_type AND principal_id = actor_subject)
-- `workspace` means everyone IN the workspace, which for humans and
-- agents is their workspace membership. A Service Account has none: it
-- belongs to a workspace without being a member of one, so its access is
-- exactly its explicit `service` grants plus drives it created (service
-- account design §7.1). Excluded here rather than at each call site --
-- that is what one shared rule is FOR.
OR (principal_type = 'workspace' AND principal_id = actor_workspace
AND actor_type <> 'service')
OR (principal_type = 'public' AND principal_id IS NULL)
$$;
-- ---------------------------------------------------------------------------
-- shares — expiring read-only bearer links (§6.9)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY
CHECK (id ~ '^shr_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
-- §6.9 distinguishes an immutable version snapshot from a live artifact or
-- folder, because sharing a live artifact exposes edits made after the fact.
resource_type TEXT NOT NULL
CHECK (resource_type IN ('artifact', 'artifact_version', 'folder')),
resource_id TEXT NOT NULL,
-- The secret is returned once at creation or rotation and stored only as a
-- hash (§6.9). There is deliberately no column that could hold the secret
-- itself — a nullable plaintext column is an invitation to populate it.
secret_hash TEXT NOT NULL,
-- Server-observed attribution (§4): who minted the share link. Like the
-- drive's `created_by_principal_id`, this is the durable record of who
-- created a credential that grants anonymous access — auditability for a
-- capability with no identity behind it. Never a client-supplied claim.
created_by_principal_type TEXT,
created_by_principal_id TEXT,
-- §4.1: shares are mutable (rotate changes the secret, revoke changes
-- state), so they carry a revision for If-Match/ETag.
revision TEXT NOT NULL
CHECK (revision ~ '^rev_[a-f0-9]{16}$'),
expires_at TIMESTAMPTZ NOT NULL,
daily_byte_limit BIGINT NOT NULL CHECK (daily_byte_limit > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS shares_secret_hash ON shares (secret_hash);
-- Replay-path backfill: a non-fresh database created `shares` before the
-- attribution columns existed. The table definition above does not add them
-- to an existing table, so this idempotent ADD closes the gap.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'shares' AND column_name = 'created_by_principal_type'
) THEN
ALTER TABLE shares
ADD COLUMN created_by_principal_type TEXT,
ADD COLUMN created_by_principal_id TEXT;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS shares_by_drive ON shares (drive_id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS shares_active_by_drive
ON shares (drive_id, resource_type, resource_id) WHERE revoked_at IS NULL;
-- `shares_list?resource_type=&resource_id=` — the links on one resource, the
-- share dialog's hot path (0045).
CREATE INDEX IF NOT EXISTS shares_by_drive_resource
ON shares (drive_id, resource_id) WHERE revoked_at IS NULL;
-- ---------------------------------------------------------------------------
-- Grants and shares are drive-scoped, and so are the resources they name
-- (§6.8, §6.9, §12A). `resource_id` is polymorphic — `resource_type` picks
-- the table — so no single composite FK can express "this resource belongs
-- to the same drive as this row". A trigger is the layer-2 mechanism for
-- exactly that, the same way `reject_cross_kind_name_collision` enforces
-- the §6.2 cross-table namespace. A bare FK on `resource_id` alone would
-- also let a grant in drive A name a resource in drive B — a state no
-- drive-scoped operation can repair.
--
-- Two rules:
-- * A `drive` grant names the drive itself: `resource_id = drive_id`
-- (and the `drive_id` FK already guarantees that drive exists).
-- * Every other resource must exist and carry this row's `drive_id`.
-- `artifact_versions` has no `drive_id` of its own; its drive is its
-- artifact's, reached through the join.
CREATE OR REPLACE FUNCTION reject_out_of_drive_resource()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
ref_drive TEXT;
BEGIN
IF NEW.resource_type = 'drive' THEN
IF NEW.resource_id IS DISTINCT FROM NEW.drive_id THEN
RAISE EXCEPTION
'a drive grant must name that drive, not %', NEW.resource_id
USING ERRCODE = 'foreign_key_violation';
END IF;
RETURN NEW;
END IF;
IF NEW.resource_type = 'folder' THEN
SELECT drive_id INTO ref_drive FROM folders WHERE id = NEW.resource_id;
ELSIF NEW.resource_type = 'artifact' THEN
SELECT drive_id INTO ref_drive FROM artifacts WHERE id = NEW.resource_id;
ELSE
SELECT a.drive_id INTO ref_drive
FROM artifact_versions v JOIN artifacts a ON a.id = v.artifact_id
WHERE v.id = NEW.resource_id;
END IF;
IF ref_drive IS NULL THEN
RAISE EXCEPTION
'resource % of type % does not exist', NEW.resource_id, NEW.resource_type
USING ERRCODE = 'foreign_key_violation';
END IF;
IF ref_drive IS DISTINCT FROM NEW.drive_id THEN
RAISE EXCEPTION
'resource % belongs to drive %, not %',
NEW.resource_id, ref_drive, NEW.drive_id
USING ERRCODE = 'foreign_key_violation';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS grants_resource_in_own_drive ON grants;
CREATE TRIGGER grants_resource_in_own_drive
BEFORE INSERT OR UPDATE OF drive_id, resource_type, resource_id ON grants
FOR EACH ROW EXECUTE FUNCTION reject_out_of_drive_resource();
DROP TRIGGER IF EXISTS shares_resource_in_own_drive ON shares;
CREATE TRIGGER shares_resource_in_own_drive
BEFORE INSERT OR UPDATE OF drive_id, resource_type, resource_id ON shares
FOR EACH ROW EXECUTE FUNCTION reject_out_of_drive_resource();
-- ---------------------------------------------------------------------------
-- viewer_sessions — short-lived hashed credentials for the private console
-- viewer (0046; 2026-08-09 private-viewer design). Minted on /v0, redeemed on
-- the isolated viewer host. The composite FK pins the session to one
-- immutable version and proves it belongs to the named artifact — a session
-- can never silently render a newer head. The credential is stored only as a
-- SHA-256 hash; there is deliberately no column that could hold the
-- plaintext. The minting principal is stored so resolution can re-check the
-- CURRENT viewer grant — revocation takes effect within one fetch.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS viewer_sessions (
id TEXT PRIMARY KEY
CHECK (id ~ '^vwr_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
artifact_id TEXT NOT NULL,
version_id TEXT NOT NULL
CHECK (version_id ~ '^ver_[a-f0-9]{16}$'),
workspace_id TEXT NOT NULL,
-- Deliberately NOT widened to `service` when the other principal columns
-- were (Token Canopy service account design §7.1). A private viewer
-- session is a narrow browser console capability minted by the Human BFF
-- path; a Service Account has no browser, and admitting one here would
-- turn a console affordance into a backend interface.
principal_type TEXT NOT NULL CHECK (principal_type IN ('agent', 'user')),
principal_id TEXT NOT NULL,
-- Snapshot of the minting token's `workspace_role` (0055): lets the
-- token-less resolution re-check honor the workspace-admin overlay for a
-- session an owner/admin minted without a grant row. NULL for agents and
-- pre-0055 rows — no overlay at re-check.
principal_workspace_role TEXT,
credential_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
FOREIGN KEY (artifact_id, version_id)
REFERENCES artifact_versions (artifact_id, id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS viewer_sessions_credential_hash
ON viewer_sessions (credential_hash);
CREATE INDEX IF NOT EXISTS viewer_sessions_expiry ON viewer_sessions (expires_at);
-- ---------------------------------------------------------------------------
-- idempotency_records — replay of executed mutations (§7.2)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS idempotency_records (
id TEXT PRIMARY KEY
CHECK (id ~ '^idem_[a-f0-9]{16}$'),
-- Scoped to the principal: one agent's key must never replay another's
-- result. The composite unique below is the real key.
principal_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
-- A repeated key with the same principal, method, path and request hash
-- returns the original result; reusing it for a different request is
-- 409 IDEMPOTENCY_CONFLICT (§7.2). Storing all three is what lets the
-- handler tell those two cases apart.
method TEXT NOT NULL,
path TEXT NOT NULL,
request_hash TEXT NOT NULL,
-- A record exists from the moment the key is CLAIMED, before any response
-- exists to store. That ordering is the whole mechanism: two concurrent
-- requests race to INSERT, exactly one wins the unique index, and the loser
-- is told the key is in flight rather than executing the mutation a second
-- time. A schema that only admitted finished records would force a
-- check-then-insert, which is the race itself.
state TEXT NOT NULL DEFAULT 'in_flight'
CHECK (state IN ('in_flight', 'completed')),
response_status INTEGER CHECK (response_status BETWEEN 100 AND 599),
response_headers JSONB NOT NULL DEFAULT '{}'::jsonb,
response_body JSONB,
-- A completed record must carry its response and an in-flight one must not:
-- otherwise a replay could return `null` as if it were the original result.
CONSTRAINT idempotency_records_response_matches_state
CHECK ((state = 'completed') = (response_status IS NOT NULL)),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT idempotency_records_principal_key UNIQUE (principal_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idempotency_records_expiry ON idempotency_records (expires_at);
-- ---------------------------------------------------------------------------
-- drive_changes — the per-drive change feed (§6.7, D14)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS drive_changes (
id TEXT PRIMARY KEY
CHECK (id ~ '^chg_[a-f0-9]{16}$'),
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
-- Dense per-drive ordering. §6.7 guarantees total order within one drive,
-- explicitly not globally — a global sequence would serialize every
-- drive's writes against every other's.
sequence BIGINT NOT NULL CHECK (sequence >= 1),
-- Recursive operations produce multiple changes sharing one set id (§6.7).
change_set_id TEXT NOT NULL,
type TEXT NOT NULL,
-- §6.7: an authenticated change actor is a Hub agent or user. Workspace and
-- `public` grant principals never act. `system` is reserved for
-- server-initiated maintenance, which uses an explicit actor and a new
-- event type rather than impersonating a human or agent.
actor_type TEXT NOT NULL
CHECK (actor_type IN ('agent', 'user', 'service', 'system')),
actor_id TEXT,
resource_type TEXT NOT NULL
CHECK (resource_type IN ('drive', 'folder', 'artifact')),
resource_id TEXT NOT NULL,
previous_revision TEXT,
revision TEXT,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT drive_changes_sequence_key UNIQUE (drive_id, sequence)
);
CREATE INDEX IF NOT EXISTS drive_changes_by_set ON drive_changes (change_set_id);
-- §6.7 + D14: the dense sequence head and the retention floor. There is
-- deliberately NO per-client cursor table: the reader carries its position in
-- a sealed token, so re-presenting a cursor re-delivers the same page. A
-- server-side position that advances at read time yields at-MOST-once
-- delivery, and the dropped page becomes unreachable — which is blocker B2,
-- made unrepresentable here rather than fixed in a handler.
CREATE TABLE IF NOT EXISTS drive_change_heads (
drive_id TEXT PRIMARY KEY REFERENCES drives(id) ON DELETE CASCADE,
last_sequence BIGINT NOT NULL DEFAULT 0 CHECK (last_sequence >= 0),
retained_from_sequence BIGINT NOT NULL DEFAULT 1 CHECK (retained_from_sequence >= 1),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The floor may sit one past the head (an empty, fully-trimmed feed) but
-- never beyond it.
CONSTRAINT drive_change_heads_floor_within_head
CHECK (retained_from_sequence <= last_sequence + 1)
);
-- ---------------------------------------------------------------------------
-- upload_sessions — B3 direct-transfer sessions (migration 0049)
--
-- Governing contract: TokenCanopy
-- docs/superpowers/specs/2026-08-14-agentdrive-direct-transfer-session-design.md §6.
-- Durable and credential-free: a resumable URI, signed URL, or provider
-- response is NEVER persisted here. Publication and cleanup are DISTINCT
-- state machines — cleanup never changes a terminal publication outcome.
-- The dormant legacy v0_uploads table below is deliberately NOT promoted.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS upload_sessions (
id TEXT PRIMARY KEY CHECK (id ~ '^upld_[a-f0-9]{16}$'),
workspace_id TEXT NOT NULL,
drive_id TEXT NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
principal_type TEXT NOT NULL
CHECK (principal_type IN ('agent', 'user', 'service')),
principal_id TEXT NOT NULL,
-- Snapshot of the minting token's `workspace_role` (0055): the
-- reconciler's token-less publication re-authorization honors the
-- workspace-admin overlay for a session an owner/admin opened without a
-- grant row. NULL for agents and pre-0055 rows.
principal_workspace_role TEXT,
-- Strict target discriminator: invalid combinations are unrepresentable.
target_kind TEXT NOT NULL CHECK (target_kind IN ('artifact', 'version')),
parent_folder_id TEXT,
artifact_name TEXT,
artifact_id TEXT,
expected_artifact_revision TEXT
CHECK (expected_artifact_revision IS NULL
OR expected_artifact_revision ~ '^rev_[a-f0-9]{16}$'),
CONSTRAINT upload_sessions_target_shape CHECK (
(target_kind = 'artifact'
AND parent_folder_id IS NOT NULL AND artifact_name IS NOT NULL