Skip to content

Commit 310ee5b

Browse files
committed
Split OuterBrain persistence store boundary
1 parent 01cc756 commit 310ee5b

20 files changed

Lines changed: 787 additions & 464 deletions

.blitz/test_state_v1/indexes/task_states.ndjson

Lines changed: 29 additions & 0 deletions
Large diffs are not rendered by default.

core/outer_brain_persistence/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ callers keep using ref-only contract records; this package remains the explicit
2424
raw Ecto/Postgres durable opt-in and is not required for default semantic
2525
runtime execution.
2626

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.
33+
2734
## Persistence Documentation
2835

2936
See `docs/persistence.md` for tiers, defaults, adapters, unsupported selections, config examples, restart claims, durability claims, debug sidecar behavior, redaction guarantees, migration or preflight behavior, and no-bypass scope when applicable.

core/outer_brain_persistence/docs/persistence.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ Unsupported adapter selections fail before mutation. Silent fallback from durabl
3333

3434
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.
3535

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+
3642
## Example Config
3743

3844
```elixir

core/outer_brain_persistence/lib/outer_brain/persistence/application.ex

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ defmodule OuterBrain.Persistence.Application do
44
use Application
55

66
@impl true
7-
def start(_type, _args) do
8-
children =
9-
if Application.get_env(:outer_brain_persistence, :enabled, false) do
10-
[OuterBrain.Persistence.Repo]
11-
else
12-
[]
13-
end
14-
15-
Supervisor.start_link(children,
7+
def start(_type, args) do
8+
Supervisor.start_link(children(args),
169
strategy: :one_for_one,
1710
name: OuterBrain.Persistence.Supervisor
1811
)
1912
end
13+
14+
@doc false
15+
@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
2023
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule OuterBrain.Persistence.JournalMapper do
2+
@moduledoc false
3+
4+
alias OuterBrain.Journal.Tables.SemanticJournalEntryRecord
5+
6+
@spec from_schema(struct()) :: SemanticJournalEntryRecord.t()
7+
def from_schema(schema) do
8+
{:ok, entry} =
9+
SemanticJournalEntryRecord.new(%{
10+
entry_id: schema.entry_id,
11+
session_id: schema.session_id,
12+
causal_unit_id: schema.causal_unit_id,
13+
entry_type: schema.entry_type,
14+
recorded_at: schema.recorded_at,
15+
payload: schema.payload
16+
})
17+
18+
entry
19+
end
20+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule OuterBrain.Persistence.LeaseMapper do
2+
@moduledoc false
3+
4+
alias OuterBrain.Contracts.Lease
5+
6+
@spec from_schema(struct()) :: Lease.t()
7+
def from_schema(schema) do
8+
%Lease{
9+
session_id: schema.session_id,
10+
holder: schema.holder,
11+
lease_id: schema.lease_id,
12+
epoch: schema.epoch,
13+
expires_at: schema.expires_at
14+
}
15+
end
16+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule OuterBrain.Persistence.RecoveryTaskMapper do
2+
@moduledoc false
3+
4+
alias OuterBrain.Journal.Tables.RecoveryTaskRecord
5+
6+
@recovery_reasons [:ambiguous_submission]
7+
@recovery_reasons_by_schema Map.new(@recovery_reasons, &{Atom.to_string(&1), &1})
8+
9+
@spec allowed_reason?(atom()) :: boolean()
10+
def allowed_reason?(reason), do: reason in @recovery_reasons
11+
12+
@spec reason_to_schema(atom()) :: String.t()
13+
def reason_to_schema(reason), do: Atom.to_string(reason)
14+
15+
@spec from_schema(struct()) :: RecoveryTaskRecord.t()
16+
def from_schema(schema) do
17+
%RecoveryTaskRecord{
18+
task_id: schema.task_id,
19+
session_id: schema.session_id,
20+
reason: recovery_reason!(schema.reason),
21+
status: schema.status
22+
}
23+
end
24+
25+
defp recovery_reason!(reason) when is_binary(reason) do
26+
case Map.fetch(@recovery_reasons_by_schema, reason) do
27+
{:ok, reason_atom} ->
28+
reason_atom
29+
30+
:error ->
31+
raise ArgumentError, "unknown recovery task reason: #{inspect(reason)}"
32+
end
33+
end
34+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule OuterBrain.Persistence.ReplyPublicationMapper do
2+
@moduledoc false
3+
4+
alias OuterBrain.Journal.Tables.ReplyPublicationRecord
5+
6+
@spec to_schema_attrs(String.t(), ReplyPublicationRecord.t()) :: map()
7+
def to_schema_attrs(tenant_id, %ReplyPublicationRecord{} = publication) do
8+
%{
9+
publication_id: publication.publication_id,
10+
tenant_id: tenant_id,
11+
causal_unit_id: publication.causal_unit_id,
12+
phase: publication.phase,
13+
state: publication.state,
14+
dedupe_key: publication.dedupe_key,
15+
body: publication.body,
16+
body_ref: publication.body_ref
17+
}
18+
end
19+
20+
@spec from_schema(struct()) :: ReplyPublicationRecord.t()
21+
def from_schema(schema) do
22+
{:ok, publication} =
23+
ReplyPublicationRecord.new(%{
24+
publication_id: schema.publication_id,
25+
causal_unit_id: schema.causal_unit_id,
26+
phase: schema.phase,
27+
state: schema.state,
28+
dedupe_key: schema.dedupe_key,
29+
body: schema.body,
30+
body_ref: schema.body_ref
31+
})
32+
33+
publication
34+
end
35+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule OuterBrain.Persistence.SemanticFailureMapper do
2+
@moduledoc false
3+
4+
alias OuterBrain.Contracts.SemanticFailure
5+
6+
@spec from_schema!(struct()) :: SemanticFailure.t()
7+
def from_schema!(schema) do
8+
case SemanticFailure.from_payload(schema.payload) do
9+
{:ok, failure} ->
10+
failure
11+
12+
{:error, reason} ->
13+
raise ArgumentError, "invalid semantic failure payload: #{inspect(reason)}"
14+
end
15+
end
16+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule OuterBrain.Persistence.OptionAccess do
2+
@moduledoc false
3+
4+
@missing :__outer_brain_persistence_option_missing__
5+
6+
@spec to_map(keyword() | map()) :: map()
7+
def to_map(opts) when is_list(opts), do: Map.new(opts)
8+
def to_map(opts) when is_map(opts), do: opts
9+
10+
@spec value(map(), atom(), term()) :: term()
11+
def value(attrs, field, default \\ nil) when is_map(attrs) and is_atom(field) do
12+
string_field = Atom.to_string(field)
13+
14+
cond do
15+
Map.has_key?(attrs, field) -> Map.fetch!(attrs, field)
16+
Map.has_key?(attrs, string_field) -> Map.fetch!(attrs, string_field)
17+
true -> default
18+
end
19+
end
20+
21+
@spec missing() :: atom()
22+
def missing, do: @missing
23+
end

0 commit comments

Comments
 (0)