Skip to content

Commit befa85a

Browse files
committed
Implement durable OuterBrain persistence
1 parent 1d2f27a commit befa85a

28 files changed

Lines changed: 1476 additions & 326 deletions

core/outer_brain_persistence/README.md

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
Raw Ecto/Postgres durability layer for restart-critical semantic-runtime state.
44

5-
Stage-1 durable tables:
5+
Canonical durable tables include:
66

77
- `semantic_session_leases`
88
- `semantic_journal_entries`
99
- `recovery_tasks`
1010
- `reply_publications`
11+
- `outer_brain_artifact_descriptors`
12+
- `outer_brain_semantic_contexts`
1113

1214
This package owns the canonical write path for those rows. In-memory runtime
1315
state may mirror hot rows, but it does not own truth.
@@ -18,18 +20,19 @@ encoded through `OuterBrain.Contracts.SemanticFailure`. Reply publication
1820
writes are idempotent by `dedupe_key`, so restart replay can update the durable
1921
publication row without creating a second user-visible publication.
2022

21-
Phase 7 persistence writes preserve the posture selected by the contract layer
22-
while keeping raw prompt and provider payload persistence disabled. Memory-mode
23-
callers keep using ref-only contract records; this package remains the explicit
24-
raw Ecto/Postgres durable opt-in and is not required for default semantic
25-
runtime execution.
26-
27-
The OTP application does not read app env to decide whether durable persistence
28-
is enabled. Hosts that need Postgres durability must supervise
29-
the persistence Repo child explicitly with their own repo config, or start it
30-
explicitly in tests with the Docker/container config. The dependency
31-
application remains inert by default so runtime configuration is not hidden in
32-
global app env.
23+
Semantic-context writes atomically persist a secret-free
24+
`GroundPlane.Contracts.ArtifactDescriptor` and immutable
25+
`OuterBrain.Contracts.SemanticContextProvenance`. The PostgreSQL full-text
26+
index covers opaque semantic, provider, model, artifact, and provenance refs;
27+
it never indexes raw prompt or provider bodies.
28+
29+
Production hosts supervise
30+
`{OuterBrain.Persistence.DurableSupervisor, profile: :durable_redacted,
31+
repo_options: [...]}`. This is the only production composition: it starts the
32+
canonical Repo and fails startup unless the live schema has every required
33+
table and migration. Missing, disabled, and memory profiles are rejected.
34+
Deterministic tests may start `OuterBrain.Persistence.Repo` directly against an
35+
isolated container; no test memory repository is compiled into production.
3336

3437
Dockerized Postgres test support uses a bounded readiness awaiter. Startup
3538
failures include the last failed probe output and remove the temporary
Lines changed: 54 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,72 @@
1-
# OuterBrain Outer Brain Persistence Persistence
1+
# OuterBrain Persistence
22

3-
## Scope
3+
## Production contract
44

5-
OuterBrain Outer Brain Persistence owns raw Ecto/Postgres durability layer for semantic-session, journal, recovery, and publication truth. This document is package-local and is the persistence contract for `core/outer_brain_persistence` in `outer_brain`.
5+
`core/outer_brain_persistence` owns PostgreSQL truth for semantic-session
6+
leases, journal entries, recovery tasks, reply publications, artifact
7+
descriptors, and semantic-context provenance. The sole production profile is
8+
`:durable_redacted`; missing, disabled, memory, and unknown profiles fail before
9+
a repository child is selected.
610

7-
## Available Tiers
8-
9-
- `:mickey_mouse`: memory or ref-only default. No restart durability claim.
10-
- `:memory_debug`: memory or ref-only with redacted debug evidence only.
11-
- `:local_restart_safe`: supported only when this package or a named adapter package owns a local durable store and preflight proof.
12-
- `:integration_postgres`: supported only when a named Postgres or AshPostgres adapter and migration proof are configured.
13-
- `:ops_durable`: supported only for Temporal-owning runtime packages after explicit substrate proof.
14-
- `:full_debug_tracked`: supported only when durable storage and redacted debug capture are both explicitly preflighted.
15-
16-
## Default Tier
17-
18-
The default tier is `:mickey_mouse`. It is memory-only or ref-only and is lost on restart unless this package explicitly states that a local durable adapter has been selected by the caller.
19-
20-
## Capture Levels
21-
22-
Supported capture levels are `:off`, `:metadata`, `:refs_only`, `:redacted_debug`, and `:full_debug` when the package explicitly supports full debug. Raw credentials, auth headers, token files, credential bodies, raw prompt bodies, raw provider payload bodies, native auth file content, private keys, session cookies, refresh tokens, access tokens, database URLs with credentials, and object-store signed URLs are always forbidden.
23-
24-
## Supported Adapters
25-
26-
Explicit Ecto/Postgres durable adapter. Not required for default semantic runtime execution.
27-
28-
## Unsupported Adapters
29-
30-
Unsupported adapter selections fail before mutation. Silent fallback from durable selection to memory is invalid. Product code must not import lower store modules directly to compensate for a missing adapter.
31-
32-
## Configuration Precedence
33-
34-
Configuration is explicit caller data first, package option second, release profile third, and built-in default last. Governed flows do not read process environment, local credential files, provider defaults, singleton clients, or application configuration as authority unless this package names a standalone boot boundary.
35-
36-
## OTP Ownership And Repo Configuration
37-
38-
`OuterBrain.Persistence.Application` is inert by default and does not read app env to start or configure Postgres. Hosts that need durable storage own the supervision decision by starting `OuterBrain.Persistence.Repo` with explicit config in their own supervision tree. Tests may start the repo directly with container-derived config.
39-
40-
This keeps durability opt-in visible at the caller boundary: no code path can silently enable Postgres persistence through global app configuration, and `OuterBrain.Persistence.Repo` receives the config that its caller supplied.
41-
42-
## Test Container Readiness
43-
44-
Dockerized Postgres test support uses a bounded readiness awaiter with explicit
45-
timeout and interval options. A readiness failure raises with the last probe's
46-
exit status and output, then removes the temporary container before returning
47-
control to the test process.
48-
49-
## Example Config
11+
Production hosts add this child to their supervision tree:
5012

5113
```elixir
52-
# Default deterministic profile.
53-
[persistence_profile: :mickey_mouse]
54-
55-
# Redacted in-memory debug profile.
56-
[persistence_profile: :memory_debug, capture_level: :redacted_debug]
57-
58-
# Durable opt-in example. The caller must also pass adapter capability and preflight proof.
59-
[persistence_profile: :integration_postgres]
14+
{OuterBrain.Persistence.DurableSupervisor,
15+
profile: :durable_redacted,
16+
repo_options: [url: database_url]}
6017
```
6118

62-
## Test Commands
19+
Hosts that start all repositories in an earlier supervision layer use
20+
`repo_mode: :external`. That mode omits the Repo child and requires the
21+
canonical Repo to be running before Bootstrap starts. A host can validate the
22+
same database before starting its Repo layer by using a bounded temporary Repo:
6323

64-
```bash
65-
cd core/outer_brain_persistence && mix test; root mix ci
24+
```elixir
25+
OuterBrain.Persistence.Store.preflight(
26+
profile: :durable_redacted,
27+
repo_mode: :temporary,
28+
repo_options: [url: database_url]
29+
)
6630
```
6731

68-
## Lost-On-Restart Claims
69-
70-
`:mickey_mouse` and `:memory_debug` data is lost on BEAM or process restart unless the package explicitly says a local durable adapter was selected. Memory profiles may prove semantics, validation, and receipt shape; they do not prove restart durability.
71-
72-
## Valid Durability Claims
32+
The supervisor starts `OuterBrain.Persistence.Repo` and then performs a live
33+
preflight. Boot fails if the Repo is unavailable, a required table is absent,
34+
or a package migration is pending. Preflight errors expose only safe error
35+
classes, never connection strings or driver exception messages. A running host
36+
may perform the same health check with:
7337

74-
Valid durability claims require explicit profile selection, adapter capability, migration or substrate preflight, redacted evidence, focused tests, repo QC, and a pushed commit. :integration_postgres after migration and repository preflight.
75-
76-
## Invalid Durability Claims
77-
78-
Invalid claims include ambient provider credentials, default database reachability, default Temporal reachability, object-store availability without opt-in, network reachability, raw debug capture, raw prompt capture, raw provider payload capture, and product direct lower-store imports.
79-
80-
## Debug Sidecar Behavior
38+
```elixir
39+
OuterBrain.Persistence.Store.preflight(
40+
profile: :durable_redacted,
41+
repo: OuterBrain.Persistence.Repo
42+
)
43+
```
8144

82-
Debug sidecars are disabled by default. When enabled, they are read-only or append-only redacted evidence surfaces. Debug failure must be non-mutating and must not alter authority, lease, run, workflow, store, projection, or product state.
45+
## Semantic context and artifact boundaries
8346

84-
## Redaction Guarantees
47+
`Store.record_semantic_context/3` atomically records an immutable,
48+
secret-free `GroundPlane.Contracts.ArtifactDescriptor` and the matching
49+
`OuterBrain.Contracts.SemanticContextProvenance`. Exact replays are idempotent;
50+
reuse of an artifact, semantic, or idempotency reference with different facts
51+
fails closed. Tenant scope is required on every write and read.
8552

86-
Evidence stores opaque refs, stable redacted ids, hashes, bounded metadata, claim-check refs, capture tags, receipt refs, store refs without credentials, and partition refs without secrets. Raw secret and raw payload fields are rejected before persistence or export.
53+
The semantic index contains only opaque semantic, provider, model, artifact,
54+
and provenance refs. Raw prompts, provider bodies, signed object-store URLs,
55+
credentials, and credential-shaped metadata are forbidden. Object locations
56+
remain opaque owner-authorized refs.
8757

88-
## Migration And Preflight Behavior
58+
## Test boundary
8959

90-
Package migrations own semantic_session_leases, semantic_journal_entries, recovery_tasks, and reply_publications.
60+
Tests may start the canonical Repo directly against the isolated Docker
61+
PostgreSQL fixture. That is a deterministic proof path, not a production
62+
selector. There is no production memory, no-op, disabled, or fixture Repo.
9163

92-
## Phase 12 Migration And Preflight Closeout
64+
Focused QC:
9365

94-
- Tier: `:integration_postgres`.
95-
- Schema owner: `OuterBrain.Persistence.Repo`.
96-
- Migration owner: `core/outer_brain_persistence/priv/repo/migrations`.
97-
- Migration command: run `Ecto.Migrator.run(OuterBrain.Persistence.Repo, OuterBrain.Persistence.PostgresContainer.migrations_path(), :up, all: true)` against the configured repo, or the equivalent release-owned migration command for `OuterBrain.Persistence.Repo`.
98-
- Migration preflight command: `OuterBrain.Persistence.Store.preflight(profile: :integration_postgres, migration_proof: :present)`.
99-
- Failure behavior: missing migration proof returns `{:error, {:missing_migration_proof, :outer_brain_persistence}}` before semantic-session, journal, recovery, or publication mutation.
100-
- Rollback behavior: rollback is an operator-owned database migration action against the same repo and migration path; restart durability claims remain open until post-rollback focused tests are recorded.
101-
- Tagged test command: `cd core/outer_brain_persistence && mix test test/outer_brain/persistence/store_test.exs`.
102-
- Release claim boundary: durable OuterBrain truth is valid only after migration proof, focused package tests, root QC, static scans, and pushed commit evidence are recorded.
66+
```bash
67+
cd core/outer_brain_persistence
68+
mix compile --warnings-as-errors
69+
mix test test/outer_brain/persistence/store_boundary_test.exs \
70+
test/outer_brain/persistence/store_test.exs \
71+
test/outer_brain/persistence/semantic_failure_store_test.exs
72+
```
Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
defmodule OuterBrain.Persistence.Application do
2-
@moduledoc false
2+
@moduledoc """
3+
Canonical durable persistence composition for an OuterBrain production host.
4+
5+
The persistence package is a library application so deterministic test
6+
fixtures can start an isolated repository. A production host must place this
7+
child specification in its supervision tree. Missing and memory profiles are
8+
rejected before a repository child can be returned.
9+
"""
310

411
use Application
512

13+
alias OuterBrain.Persistence.DurableSupervisor
14+
615
@impl true
7-
def start(_type, args) do
8-
Supervisor.start_link(children(args),
9-
strategy: :one_for_one,
10-
name: OuterBrain.Persistence.Supervisor
11-
)
12-
end
16+
def start(_type, args), do: DurableSupervisor.start_link(args)
1317

1418
@doc false
1519
@spec children(keyword()) :: [Supervisor.child_spec() | module() | {module(), term()}]
16-
def children(args) when is_list(args) do
17-
if Keyword.get(args, :enabled, false) do
18-
[Keyword.get(args, :repo_child, OuterBrain.Persistence.Repo)]
19-
else
20-
[]
21-
end
22-
end
20+
def children(args) when is_list(args), do: DurableSupervisor.children(args)
2321
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
defmodule OuterBrain.Persistence.ArtifactMetadataPolicy do
2+
@moduledoc false
3+
4+
alias GroundPlane.Contracts.ArtifactDescriptor
5+
6+
@forbidden_keys MapSet.new(~w(database_url presigned_url signed_url))
7+
@signed_location_markers ~w(x-amz-signature x-goog-signature signature= access_token= token=)
8+
9+
@spec validate(ArtifactDescriptor.t()) :: :ok | {:error, term()}
10+
def validate(%ArtifactDescriptor{} = descriptor) do
11+
with :ok <- reject_forbidden_key(descriptor.provenance),
12+
:ok <- reject_forbidden_key(descriptor.retention),
13+
:ok <- validate_location_ref(descriptor.location_ref) do
14+
:ok
15+
end
16+
end
17+
18+
defp reject_forbidden_key(map) when is_map(map) do
19+
Enum.reduce_while(map, :ok, fn {key, value}, :ok ->
20+
normalized_key = key |> to_string() |> String.downcase()
21+
22+
cond do
23+
MapSet.member?(@forbidden_keys, normalized_key) ->
24+
{:halt, {:error, {:secret_artifact_metadata_key, normalized_key}}}
25+
26+
true ->
27+
case reject_forbidden_key(value) do
28+
:ok -> {:cont, :ok}
29+
{:error, _reason} = error -> {:halt, error}
30+
end
31+
end
32+
end)
33+
end
34+
35+
defp reject_forbidden_key(list) when is_list(list) do
36+
Enum.reduce_while(list, :ok, fn value, :ok ->
37+
case reject_forbidden_key(value) do
38+
:ok -> {:cont, :ok}
39+
{:error, _reason} = error -> {:halt, error}
40+
end
41+
end)
42+
end
43+
44+
defp reject_forbidden_key(_value), do: :ok
45+
46+
defp validate_location_ref(nil), do: :ok
47+
48+
defp validate_location_ref(location_ref) do
49+
normalized = String.downcase(location_ref)
50+
51+
if String.starts_with?(normalized, ["http://", "https://"]) or
52+
Enum.any?(@signed_location_markers, &String.contains?(normalized, &1)) do
53+
{:error, :non_opaque_artifact_location_ref}
54+
else
55+
:ok
56+
end
57+
end
58+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule OuterBrain.Persistence.Bootstrap do
2+
@moduledoc false
3+
4+
use GenServer
5+
6+
alias OuterBrain.Persistence.ProfilePolicy
7+
8+
@spec start_link(keyword()) :: GenServer.on_start()
9+
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
10+
11+
@impl true
12+
def init(opts) do
13+
case ProfilePolicy.preflight(opts) do
14+
:ok -> {:ok, %{repo: Keyword.fetch!(opts, :repo)}}
15+
{:error, reason} -> {:stop, {:outer_brain_durable_repository_unavailable, reason}}
16+
end
17+
end
18+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule OuterBrain.Persistence.DurableSupervisor do
2+
@moduledoc """
3+
Fail-closed production composition for the canonical PostgreSQL repository.
4+
5+
The repository starts first. The bootstrap child then verifies the live
6+
schema and every local migration before the supervisor can finish starting.
7+
"""
8+
9+
use Supervisor
10+
11+
alias OuterBrain.Persistence.{Bootstrap, ProfilePolicy, Repo}
12+
13+
@spec start_link(keyword()) :: Supervisor.on_start()
14+
def start_link(opts) when is_list(opts) do
15+
:ok = ProfilePolicy.require_durable_profile(opts)
16+
17+
case Keyword.get(opts, :name, __MODULE__) do
18+
nil -> Supervisor.start_link(__MODULE__, opts)
19+
name -> Supervisor.start_link(__MODULE__, opts, name: name)
20+
end
21+
end
22+
23+
@impl true
24+
def init(opts), do: Supervisor.init(children(opts), strategy: :one_for_all)
25+
26+
@doc false
27+
@spec children(keyword()) :: [Supervisor.child_spec() | {module(), keyword()}]
28+
def children(opts) do
29+
:ok = ProfilePolicy.require_durable_profile(opts)
30+
31+
bootstrap = {Bootstrap, profile: :durable_redacted, repo: Repo}
32+
33+
case Keyword.get(opts, :repo_mode, :owned) do
34+
:owned -> [{Repo, Keyword.get(opts, :repo_options, [])}, bootstrap]
35+
:external -> [bootstrap]
36+
mode -> raise ArgumentError, "unsupported OuterBrain Repo mode: #{inspect(mode)}"
37+
end
38+
end
39+
end

0 commit comments

Comments
 (0)