lorebase-store 0.2.3__py3-none-any.whl

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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,205 @@
1
+ """Vectors keyed by embedding template fingerprint (design 9.3).
2
+
3
+ `embeddings.fingerprint` holds the fingerprint of the embedding template the vector's text was
4
+ rendered with (`KindTemplate.fingerprint`) and joins the primary key, now `(domain_id,
5
+ object_hash, model, fingerprint)`: branches whose templates differ for the same content keep a
6
+ vector each, and readers look a vector up with the fingerprint of the template of the branch
7
+ they read.
8
+
9
+ Existing rows. The fingerprint of a vector is set when it can be told: the live Records
10
+ holding its object hash, in every materialized branch whose schema gives their Kind an
11
+ embedding template, all have a template of the same fingerprint. Other vectors keep the empty
12
+ fingerprint, which no reader looks up and garbage collection deletes. Every
13
+ `embedding_cursors` row loses its fingerprints, so the next pass of each branch renders each
14
+ Record's text once more: a Record whose text hash matches its vector costs nothing, one whose
15
+ text matches another stored text gets that vector copied without a model call (vectors left
16
+ without a fingerprint included, unless a collection deleted them first), and only texts
17
+ embedded nowhere go to the model. Until that pass, semantic search finds the vectors whose
18
+ fingerprint was set.
19
+
20
+ The downgrade keeps one vector per `(domain_id, object_hash, model)` and clears the cursors'
21
+ fingerprints the same way.
22
+
23
+ Revision ID: 0024
24
+ Revises: 0023
25
+ Create Date: 2026-10-27 09:00:00.000000
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import re
33
+ from collections.abc import Mapping, Sequence
34
+ from typing import Any
35
+
36
+ import sqlalchemy as sa
37
+ from alembic import op
38
+ from sqlalchemy.dialects import postgresql
39
+
40
+ revision: str = "0024"
41
+ down_revision: str | Sequence[str] | None = "0023"
42
+ branch_labels: str | Sequence[str] | None = None
43
+ depends_on: str | Sequence[str] | None = None
44
+
45
+ TEMPLATES = "lb_migration_templates"
46
+
47
+ _FILL = sa.text(
48
+ f"INSERT INTO {TEMPLATES} (schema_hash, kind_id, fingerprint) "
49
+ "SELECT :h, k, f FROM unnest(CAST(:kinds AS text[]), CAST(:fps AS text[])) AS t(k, f)"
50
+ ).bindparams(
51
+ sa.bindparam("kinds", type_=postgresql.ARRAY(sa.Text())),
52
+ sa.bindparam("fps", type_=postgresql.ARRAY(sa.Text())),
53
+ )
54
+
55
+ _BACKFILL = sa.text(
56
+ f"""
57
+ UPDATE embeddings e SET fingerprint = known.fingerprint
58
+ FROM (
59
+ SELECT m.domain_id, m.object_hash, min(t.fingerprint) AS fingerprint
60
+ FROM mat_records m
61
+ JOIN mat_schema s ON s.domain_id = m.domain_id AND s.branch = m.branch
62
+ JOIN {TEMPLATES} t ON t.schema_hash = s.schema_hash AND t.kind_id = m.kind_id
63
+ WHERE NOT m.deleted
64
+ GROUP BY m.domain_id, m.object_hash
65
+ HAVING count(DISTINCT t.fingerprint) = 1
66
+ ) AS known
67
+ WHERE e.domain_id = known.domain_id AND e.object_hash = known.object_hash
68
+ """
69
+ )
70
+ """A vector gets the fingerprint every live Record holding its object hash agrees on."""
71
+
72
+
73
+ # Fingerprints as `lorebase_core.model.embedding` computed them at this revision (rendering
74
+ # version 1), on the stored compiled schema document. Frozen here so the backfill does not
75
+ # change with the application code.
76
+ _RENDERING_VERSION = 1
77
+ _KIND_FIELD = "_kind"
78
+ _SPECIAL_FIELDS = frozenset({_KIND_FIELD, "_key"})
79
+ _TOKEN = re.compile(r"\{\{|\}\}|\{([^{}]*)\}|[{}]")
80
+
81
+
82
+ def _template_fields(source: str) -> tuple[str, ...] | None:
83
+ """Placeholder names in order of first use; `None` when the template cannot be parsed."""
84
+ names: list[str] = []
85
+ for match in _TOKEN.finditer(source):
86
+ token = match.group(0)
87
+ if token in ("{{", "}}"):
88
+ continue
89
+ name = match.group(1)
90
+ if name is None or not name.strip():
91
+ return None
92
+ names.append(name.strip())
93
+ return tuple(dict.fromkeys(names)) or None
94
+
95
+
96
+ def _kind_fingerprint(kind: Mapping[str, Any]) -> str | None:
97
+ """Fingerprint of the Kind's embedding template; `None` without a usable one."""
98
+ search = kind.get("search") or {}
99
+ source = search.get("embedding_template")
100
+ if source is None or not source.strip():
101
+ return None
102
+ fields = _template_fields(source)
103
+ if fields is None:
104
+ return None
105
+ allow_sensitive = bool(search.get("embedding_sensitive"))
106
+ attributes: dict[str, str] = {}
107
+ for name in fields:
108
+ if name in _SPECIAL_FIELDS:
109
+ continue
110
+ attribute = next(
111
+ (a for a in (kind.get("attributes") or {}).values() if a["api_name"] == name), None
112
+ )
113
+ if (
114
+ attribute is None
115
+ or attribute.get("derivation_id") is not None
116
+ or attribute.get("vault") is True
117
+ or (attribute.get("sensitivity") and not allow_sensitive)
118
+ ):
119
+ return None
120
+ attributes[name] = attribute["id"]
121
+ label = kind.get("display_name") or kind["api_name"]
122
+ return hashlib.sha256(
123
+ json.dumps(
124
+ {
125
+ "v": _RENDERING_VERSION,
126
+ "kind": kind["id"],
127
+ "template": source,
128
+ "attributes": attributes,
129
+ "label": label if _KIND_FIELD in fields else None,
130
+ },
131
+ sort_keys=True,
132
+ separators=(",", ":"),
133
+ ).encode("utf-8")
134
+ ).hexdigest()
135
+
136
+
137
+ def _template_fingerprints(compiled: Mapping[str, Any]) -> dict[str, str]:
138
+ """Fingerprint by Kind id of each Kind of a compiled schema with a usable template."""
139
+ found: dict[str, str] = {}
140
+ for kind in (compiled.get("kinds") or {}).values():
141
+ fingerprint = _kind_fingerprint(kind)
142
+ if fingerprint is not None:
143
+ found[kind["id"]] = fingerprint
144
+ return found
145
+
146
+
147
+ def _templates(bind: sa.Connection) -> None:
148
+ bind.execute(
149
+ sa.text(
150
+ f"CREATE TEMP TABLE {TEMPLATES} (schema_hash bytea NOT NULL, kind_id text NOT NULL, "
151
+ "fingerprint text NOT NULL, PRIMARY KEY (schema_hash, kind_id)) ON COMMIT DROP"
152
+ )
153
+ )
154
+ schemas = bind.execute(
155
+ sa.text("SELECT DISTINCT ON (schema_hash) schema_hash, compiled FROM mat_schema")
156
+ ).all()
157
+ for schema_hash, compiled in schemas:
158
+ fingerprints = _template_fingerprints(compiled)
159
+ if fingerprints:
160
+ bind.execute(
161
+ _FILL,
162
+ {
163
+ "h": bytes(schema_hash),
164
+ "kinds": list(fingerprints),
165
+ "fps": list(fingerprints.values()),
166
+ },
167
+ )
168
+
169
+
170
+ def _clear_cursor_fingerprints() -> None:
171
+ op.execute("UPDATE embedding_cursors SET fingerprints = '{}'::jsonb")
172
+
173
+
174
+ def upgrade() -> None:
175
+ bind = op.get_bind()
176
+ # Row-level security is forced on the owner too: read and write the rows of every Domain.
177
+ bind.execute(sa.text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
178
+ op.add_column(
179
+ "embeddings",
180
+ sa.Column("fingerprint", sa.Text(), nullable=False, server_default=sa.text("''")),
181
+ )
182
+ op.alter_column("embeddings", "fingerprint", server_default=None)
183
+ op.drop_constraint(op.f("pk_embeddings"), "embeddings", type_="primary")
184
+ op.create_primary_key(
185
+ op.f("pk_embeddings"), "embeddings", ["domain_id", "object_hash", "model", "fingerprint"]
186
+ )
187
+ _templates(bind)
188
+ bind.execute(_BACKFILL)
189
+ _clear_cursor_fingerprints()
190
+
191
+
192
+ def downgrade() -> None:
193
+ bind = op.get_bind()
194
+ bind.execute(sa.text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
195
+ op.execute(
196
+ "DELETE FROM embeddings e USING embeddings other "
197
+ "WHERE other.domain_id = e.domain_id AND other.object_hash = e.object_hash "
198
+ "AND other.model = e.model AND other.fingerprint > e.fingerprint"
199
+ )
200
+ op.drop_constraint(op.f("pk_embeddings"), "embeddings", type_="primary")
201
+ op.drop_column("embeddings", "fingerprint")
202
+ op.create_primary_key(
203
+ op.f("pk_embeddings"), "embeddings", ["domain_id", "object_hash", "model"]
204
+ )
205
+ _clear_cursor_fingerprints()
@@ -0,0 +1,35 @@
1
+ """Snapshot refs.
2
+
3
+ Adds `snapshot` to `ref_kind`: a snapshot ref holds the materialized rows of a past Revision
4
+ for reads at it (`lorebase_store.materialize.snapshot`). The value is added outside the
5
+ migration transaction, as Postgres requires before it can be used.
6
+
7
+ Downgrade deletes the snapshot refs (their rows go with them) and keeps the enum value:
8
+ Postgres cannot drop a value from an enum type, and no other ref uses it. The deletion is one
9
+ statement: snapshots are bounded (`read.time_travel.snapshot_max_per_domain` per Domain, each
10
+ at most `snapshot_max_entries` Records and Links plus its history), so it is not batched.
11
+
12
+ Revision ID: 0025
13
+ Revises: 0024
14
+ Create Date: 2026-10-27 09:00:00.000000
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+
21
+ from alembic import op
22
+
23
+ revision: str = "0025"
24
+ down_revision: str | Sequence[str] | None = "0024"
25
+ branch_labels: str | Sequence[str] | None = None
26
+ depends_on: str | Sequence[str] | None = None
27
+
28
+
29
+ def upgrade() -> None:
30
+ with op.get_context().autocommit_block():
31
+ op.execute("ALTER TYPE ref_kind ADD VALUE IF NOT EXISTS 'snapshot'")
32
+
33
+
34
+ def downgrade() -> None:
35
+ op.execute("DELETE FROM refs WHERE kind = 'snapshot'")
@@ -0,0 +1,112 @@
1
+ """The plain reverse Link index again.
2
+
3
+ An earlier version of this revision replaced `ix_mat_links_reverse` with a covering index
4
+ (`INCLUDE (from_key, "values", ...)`) so that inbound Link hops of the neighborhood would be
5
+ index-only scans. It was withdrawn: a B-tree entry is limited to about 2.7 KB, so a Link with
6
+ large `values` could no longer be written ("index row size exceeds maximum"), and without
7
+ `values` the hop, which returns them, reads the heap anyway while the index doubles in size.
8
+ On a database that ran the withdrawn version this restores the plain index (built
9
+ concurrently per partition, revision 0011 style) and drops the covering one; elsewhere it
10
+ changes nothing.
11
+
12
+ Revision ID: 0026
13
+ Revises: 0025
14
+ Create Date: 2026-10-22 18:00:00.000000
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+ from typing import NamedTuple
21
+
22
+ import sqlalchemy as sa
23
+ from alembic import op
24
+
25
+ revision: str = "0026"
26
+ down_revision: str | Sequence[str] | None = "0025"
27
+ branch_labels: str | Sequence[str] | None = None
28
+ depends_on: str | Sequence[str] | None = None
29
+
30
+ PARENT = "mat_links"
31
+
32
+
33
+ class BtreeIndex(NamedTuple):
34
+ name: str
35
+ definition: str
36
+ suffix: str
37
+ """Partition indexes are named `<partition>_<suffix>`."""
38
+
39
+
40
+ OLD = BtreeIndex(
41
+ "ix_mat_links_reverse",
42
+ "(domain_id, branch, relation_id, to_key)",
43
+ "domain_id_branch_relation_id_to_key_idx",
44
+ )
45
+ NEW = BtreeIndex(
46
+ "ix_mat_links_reverse_covering",
47
+ '(domain_id, branch, relation_id, to_key) INCLUDE (from_key, "values", object_hash, '
48
+ "revision_generation, deleted)",
49
+ "reverse_covering_idx",
50
+ )
51
+
52
+
53
+ def _partitions() -> list[str]:
54
+ found = op.get_bind().execute(
55
+ sa.text(
56
+ "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid "
57
+ "WHERE i.inhparent = CAST(:parent AS regclass) ORDER BY c.relname"
58
+ ),
59
+ {"parent": PARENT},
60
+ )
61
+ return list(found.scalars())
62
+
63
+
64
+ def _state(name: str) -> tuple[bool, bool] | None:
65
+ """`(valid, attached)` of an index, `None` when it does not exist."""
66
+ row = (
67
+ op.get_bind()
68
+ .execute(
69
+ sa.text(
70
+ "SELECT x.indisvalid, EXISTS (SELECT 1 FROM pg_inherits i "
71
+ "WHERE i.inhrelid = c.oid) FROM pg_class c JOIN pg_index x "
72
+ "ON x.indexrelid = c.oid WHERE c.relname = :name "
73
+ "AND c.relnamespace = (SELECT relnamespace FROM pg_class "
74
+ "WHERE oid = CAST(:parent AS regclass))"
75
+ ),
76
+ {"name": name, "parent": PARENT},
77
+ )
78
+ .first()
79
+ )
80
+ return None if row is None else (bool(row[0]), bool(row[1]))
81
+
82
+
83
+ def _build(index: BtreeIndex) -> None:
84
+ """Build the index on every partition without blocking writes."""
85
+ op.execute(f"CREATE INDEX IF NOT EXISTS {index.name} ON ONLY {PARENT} {index.definition}")
86
+ with op.get_context().autocommit_block():
87
+ for partition in _partitions():
88
+ name = f"{partition}_{index.suffix}"
89
+ state = _state(name)
90
+ if state is not None and not state[0]:
91
+ op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
92
+ state = None
93
+ if state is None:
94
+ op.execute(f"CREATE INDEX CONCURRENTLY {name} ON {partition} {index.definition}")
95
+ if state is None or not state[1]:
96
+ op.execute(f"ALTER INDEX {index.name} ATTACH PARTITION {name}")
97
+ if _state(index.name) != (True, False):
98
+ raise RuntimeError(f"index {index.name} is not valid after attaching every partition")
99
+
100
+
101
+ def _swap(old: BtreeIndex, new: BtreeIndex) -> None:
102
+ """Build `new`, then drop `old` once `new` is valid."""
103
+ _build(new)
104
+ op.execute(f"DROP INDEX IF EXISTS {old.name}")
105
+
106
+
107
+ def upgrade() -> None:
108
+ _swap(NEW, OLD)
109
+
110
+
111
+ def downgrade() -> None:
112
+ """Nothing to undo: the plain index is the one revision 0025 had."""
@@ -0,0 +1,145 @@
1
+ """Vault key rotation, erasure tombstones and lineage, installation secrets (design 13.5).
2
+
3
+ - `vault.fingerprint_key` names the token key and scheme a row's fingerprint was made under
4
+ (`<token_key_id>/<scheme>`); rows stored before have none and are made again by the rotation
5
+ job (`lorebase_store.vault.rotation`).
6
+ - `vault_tombstones` keeps the fingerprints of erased values, so a later write of one is stored
7
+ erased or refused (`vault.erased_values`).
8
+ - `vault_lineage` records a Record that took over the vaulted values of another key of its Kind
9
+ (a key change), so erasing one erases both.
10
+ - `installation_secrets` holds a random secret made here: the key of the keyed digests
11
+ (Operation parameters, erased Record keys in the audit log) when no `vault.digest_key` and no
12
+ vault keys are configured.
13
+
14
+ Revision ID: 0027
15
+ Revises: 0026
16
+ Create Date: 2026-10-27 09:00:00.000000
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+
23
+ import sqlalchemy as sa
24
+ from alembic import op
25
+
26
+ revision: str = "0027"
27
+ down_revision: str | Sequence[str] | None = "0026"
28
+ branch_labels: str | Sequence[str] | None = None
29
+ depends_on: str | Sequence[str] | None = None
30
+
31
+ TOMBSTONES = "vault_tombstones"
32
+ LINEAGE = "vault_lineage"
33
+ SECRETS = "installation_secrets"
34
+
35
+ DOMAIN_VISIBLE = (
36
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
37
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
38
+ )
39
+
40
+
41
+ def _isolate(table: str) -> None:
42
+ op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
43
+ op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
44
+ op.execute(
45
+ f"CREATE POLICY domain_isolation ON {table} "
46
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
47
+ )
48
+
49
+
50
+ def upgrade() -> None:
51
+ op.add_column("vault", sa.Column("fingerprint_key", sa.Text(), nullable=True))
52
+
53
+ op.create_table(
54
+ TOMBSTONES,
55
+ sa.Column("domain_id", sa.Text(), nullable=False),
56
+ sa.Column("seq", sa.BigInteger(), sa.Identity(always=True), nullable=False),
57
+ sa.Column("kind_id", sa.Text(), nullable=False),
58
+ sa.Column("attribute_id", sa.Text(), nullable=False),
59
+ sa.Column("record_key", sa.Text(), nullable=True),
60
+ sa.Column("fingerprint", sa.LargeBinary(), nullable=False),
61
+ sa.Column("fingerprint_key", sa.Text(), nullable=True),
62
+ sa.Column(
63
+ "created_at",
64
+ sa.DateTime(timezone=True),
65
+ server_default=sa.text("now()"),
66
+ nullable=False,
67
+ ),
68
+ sa.ForeignKeyConstraint(
69
+ ["domain_id"],
70
+ ["domains.id"],
71
+ name=op.f("fk_vault_tombstones_domain_id_domains"),
72
+ ondelete="CASCADE",
73
+ ),
74
+ sa.PrimaryKeyConstraint("domain_id", "seq", name=op.f("pk_vault_tombstones")),
75
+ )
76
+ op.create_index(
77
+ op.f("ix_vault_tombstones_domain_id_fingerprint"), TOMBSTONES, ["domain_id", "fingerprint"]
78
+ )
79
+ _isolate(TOMBSTONES)
80
+
81
+ op.create_table(
82
+ LINEAGE,
83
+ sa.Column("domain_id", sa.Text(), nullable=False),
84
+ sa.Column("kind_id", sa.Text(), nullable=False),
85
+ sa.Column("record_key", sa.Text(), nullable=False),
86
+ sa.Column("previous_key", sa.Text(), nullable=False),
87
+ sa.Column(
88
+ "created_at",
89
+ sa.DateTime(timezone=True),
90
+ server_default=sa.text("now()"),
91
+ nullable=False,
92
+ ),
93
+ sa.ForeignKeyConstraint(
94
+ ["domain_id"],
95
+ ["domains.id"],
96
+ name=op.f("fk_vault_lineage_domain_id_domains"),
97
+ ondelete="CASCADE",
98
+ ),
99
+ sa.PrimaryKeyConstraint(
100
+ "domain_id", "kind_id", "record_key", "previous_key", name=op.f("pk_vault_lineage")
101
+ ),
102
+ )
103
+ op.create_index(
104
+ op.f("ix_vault_lineage_domain_id_kind_id_previous_key"),
105
+ LINEAGE,
106
+ ["domain_id", "kind_id", "previous_key"],
107
+ )
108
+ _isolate(LINEAGE)
109
+
110
+ op.create_table(
111
+ SECRETS,
112
+ sa.Column("name", sa.Text(), nullable=False),
113
+ sa.Column("secret", sa.Text(), nullable=False),
114
+ sa.Column(
115
+ "created_at",
116
+ sa.DateTime(timezone=True),
117
+ server_default=sa.text("now()"),
118
+ nullable=False,
119
+ ),
120
+ sa.PrimaryKeyConstraint("name", name=op.f("pk_installation_secrets")),
121
+ )
122
+ # 244 random bits from the server's cryptographic generator (gen_random_uuid, Postgres 13+).
123
+ op.execute(
124
+ f"INSERT INTO {SECRETS} (name, secret) "
125
+ "VALUES ('digest', replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''))"
126
+ )
127
+
128
+
129
+ def downgrade() -> None:
130
+ bind = op.get_bind()
131
+ # Row-level security is forced on the owner too: count the rows of every Domain.
132
+ bind.execute(sa.text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
133
+ for table in (TOMBSTONES, LINEAGE):
134
+ stored = bind.execute(sa.text(f"SELECT count(*) FROM {table}")).scalar_one()
135
+ if stored:
136
+ raise RuntimeError(
137
+ f"{stored} row(s) in {table}: without them erased values could be stored again "
138
+ "and erasure would miss earlier keys; keep this revision"
139
+ )
140
+ op.drop_table(SECRETS)
141
+ op.drop_index(op.f("ix_vault_lineage_domain_id_kind_id_previous_key"), table_name=LINEAGE)
142
+ op.drop_table(LINEAGE)
143
+ op.drop_index(op.f("ix_vault_tombstones_domain_id_fingerprint"), table_name=TOMBSTONES)
144
+ op.drop_table(TOMBSTONES)
145
+ op.drop_column("vault", "fingerprint_key")
@@ -0,0 +1,71 @@
1
+ """Deferred Operation events.
2
+
3
+ `operation_deferred_events` holds the `operation.<side effect>` events of executions committed
4
+ on a branch whose Proposal is their review (agent writes). The merge that brings the execution
5
+ into its target writes them to the outbox and deletes them, in its transaction. Under the
6
+ `domain_isolation` policy like every table with a `domain_id`.
7
+
8
+ Revision ID: 0028
9
+ Revises: 0027
10
+ Create Date: 2026-10-27 10:00:00.000000
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+
17
+ import sqlalchemy as sa
18
+ from alembic import op
19
+ from sqlalchemy.dialects import postgresql
20
+
21
+ revision: str = "0028"
22
+ down_revision: str | Sequence[str] | None = "0027"
23
+ branch_labels: str | Sequence[str] | None = None
24
+ depends_on: str | Sequence[str] | None = None
25
+
26
+ DEFERRED = "operation_deferred_events"
27
+
28
+ DOMAIN_VISIBLE = (
29
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
30
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
31
+ )
32
+
33
+
34
+ def upgrade() -> None:
35
+ op.create_table(
36
+ DEFERRED,
37
+ sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
38
+ sa.Column("domain_id", sa.Text(), nullable=False),
39
+ sa.Column("branch", sa.Text(), nullable=False),
40
+ sa.Column("revision_id", sa.LargeBinary(), nullable=False),
41
+ sa.Column("invocation_id", sa.Text(), nullable=False),
42
+ sa.Column("event_type", sa.Text(), nullable=False),
43
+ sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
44
+ sa.Column(
45
+ "created_at",
46
+ sa.DateTime(timezone=True),
47
+ server_default=sa.text("now()"),
48
+ nullable=False,
49
+ ),
50
+ sa.ForeignKeyConstraint(
51
+ ["domain_id"],
52
+ ["domains.id"],
53
+ name=op.f("fk_operation_deferred_events_domain_id_domains"),
54
+ ondelete="CASCADE",
55
+ ),
56
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_deferred_events")),
57
+ )
58
+ op.create_index(
59
+ op.f("ix_operation_deferred_events_domain_id_branch"), DEFERRED, ["domain_id", "branch"]
60
+ )
61
+ op.execute(f"ALTER TABLE {DEFERRED} ENABLE ROW LEVEL SECURITY")
62
+ op.execute(f"ALTER TABLE {DEFERRED} FORCE ROW LEVEL SECURITY")
63
+ op.execute(
64
+ f"CREATE POLICY domain_isolation ON {DEFERRED} "
65
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
66
+ )
67
+
68
+
69
+ def downgrade() -> None:
70
+ op.drop_index(op.f("ix_operation_deferred_events_domain_id_branch"), table_name=DEFERRED)
71
+ op.drop_table(DEFERRED)
@@ -0,0 +1,108 @@
1
+ """Agent trace session rows.
2
+
3
+ `agent_trace_sessions` holds the number of steps of each session and actor: the trace writer
4
+ raises it under the row lock in the transaction writing the steps, so the per-session cap holds
5
+ across Keepers. `agent_trace_session_domains` summarizes the steps of a session per Domain
6
+ (calls, notes, first and last step, tools, Proposals); sessions are listed from it instead of
7
+ grouping the steps of the retention period. Both are filled from the existing steps.
8
+
9
+ Revision ID: 0029
10
+ Revises: 0028
11
+ Create Date: 2026-10-27 11:00:00.000000
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+
18
+ import sqlalchemy as sa
19
+ from alembic import op
20
+ from sqlalchemy.dialects import postgresql
21
+
22
+ revision: str = "0029"
23
+ down_revision: str | Sequence[str] | None = "0028"
24
+ branch_labels: str | Sequence[str] | None = None
25
+ depends_on: str | Sequence[str] | None = None
26
+
27
+ SESSIONS = "agent_trace_sessions"
28
+ DOMAINS = "agent_trace_session_domains"
29
+
30
+ BYPASS = "current_setting('lorebase.bypass_rls', true) = 'on'"
31
+
32
+ DOMAIN_VISIBLE = (
33
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
34
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
35
+ )
36
+
37
+
38
+ def upgrade() -> None:
39
+ op.create_table(
40
+ SESSIONS,
41
+ sa.Column("actor", sa.Text(), nullable=False),
42
+ sa.Column("session_id", sa.Text(), nullable=False),
43
+ sa.Column("steps", sa.Integer(), nullable=False),
44
+ sa.Column("last_at", sa.DateTime(timezone=True), nullable=False),
45
+ sa.PrimaryKeyConstraint("actor", "session_id", name=op.f("pk_agent_trace_sessions")),
46
+ )
47
+ op.create_table(
48
+ DOMAINS,
49
+ sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
50
+ sa.Column("actor", sa.Text(), nullable=False),
51
+ sa.Column("session_id", sa.Text(), nullable=False),
52
+ sa.Column("domain_id", sa.Text(), nullable=True),
53
+ sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
54
+ sa.Column("last_at", sa.DateTime(timezone=True), nullable=False),
55
+ sa.Column("calls", sa.Integer(), nullable=False),
56
+ sa.Column("notes", sa.Integer(), nullable=False),
57
+ sa.Column("inferred", sa.Boolean(), nullable=False),
58
+ sa.Column("tools", postgresql.ARRAY(sa.Text()), nullable=False),
59
+ sa.Column("proposals", postgresql.ARRAY(sa.Text()), nullable=False),
60
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_agent_trace_session_domains")),
61
+ sa.UniqueConstraint(
62
+ "actor",
63
+ "session_id",
64
+ "domain_id",
65
+ name=op.f("uq_agent_trace_session_domains_actor_session_id_domain_id"),
66
+ postgresql_nulls_not_distinct=True,
67
+ ),
68
+ )
69
+ op.create_index(
70
+ op.f("ix_agent_trace_session_domains_actor_last_at"), DOMAINS, ["actor", "last_at"]
71
+ )
72
+ op.create_index(
73
+ op.f("ix_agent_trace_session_domains_domain_id_last_at"), DOMAINS, ["domain_id", "last_at"]
74
+ )
75
+ # No Domain: readable and writable only with row-level security bypassed, as the trace
76
+ # repository does.
77
+ op.execute(f"ALTER TABLE {SESSIONS} ENABLE ROW LEVEL SECURITY")
78
+ op.execute(f"ALTER TABLE {SESSIONS} FORCE ROW LEVEL SECURITY")
79
+ op.execute(f"CREATE POLICY bypass_only ON {SESSIONS} USING ({BYPASS}) WITH CHECK ({BYPASS})")
80
+ op.execute(f"ALTER TABLE {DOMAINS} ENABLE ROW LEVEL SECURITY")
81
+ op.execute(f"ALTER TABLE {DOMAINS} FORCE ROW LEVEL SECURITY")
82
+ op.execute(
83
+ f"CREATE POLICY domain_isolation ON {DOMAINS} "
84
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
85
+ )
86
+ op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
87
+ op.execute(
88
+ f"INSERT INTO {SESSIONS} (actor, session_id, steps, last_at) "
89
+ "SELECT actor, session_id, count(*), max(at) FROM agent_trace_steps "
90
+ "GROUP BY actor, session_id"
91
+ )
92
+ op.execute(
93
+ f"INSERT INTO {DOMAINS} (actor, session_id, domain_id, started_at, last_at, calls, "
94
+ "notes, inferred, tools, proposals) "
95
+ "SELECT actor, session_id, domain_id, min(at), max(at), "
96
+ "count(*) FILTER (WHERE type = 'call'), count(*) FILTER (WHERE type = 'note'), "
97
+ "bool_or(inferred), "
98
+ "array_remove(array_agg(DISTINCT tool), NULL), "
99
+ "array_remove(array_agg(DISTINCT proposal_id), NULL) "
100
+ "FROM agent_trace_steps GROUP BY actor, session_id, domain_id"
101
+ )
102
+
103
+
104
+ def downgrade() -> None:
105
+ op.drop_index(op.f("ix_agent_trace_session_domains_domain_id_last_at"), table_name=DOMAINS)
106
+ op.drop_index(op.f("ix_agent_trace_session_domains_actor_last_at"), table_name=DOMAINS)
107
+ op.drop_table(DOMAINS)
108
+ op.drop_table(SESSIONS)