Derive the at-rest claim from the columns instead of stating it

639f024fd8f0 · AtlantisPleb · · parent 2a9bd5e65de8

Derive the at-rest claim from the columns instead of stating it

`encrypted_at_rest` was a literal `false` on the status page, and a literal
cannot fail. The sentence behind it in EXIT-006 was also wrong: it said no
Ecto column in this repository is encrypted at rest, and three are.

The inventory came first, because "encrypt the sensitive columns" is not a
decision until someone names them. Every migration was read and every column
the catalog reports under secret-shaped vocabulary was classified. Reversible
secret material rests in exactly three columns and all three are sealed under
their own vault key; every other bearer credential rests as a one-way SHA-256
digest; external provider credentials never enter PostgreSQL. There was no
plaintext secret column to encrypt. The gap is in content, not credentials,
and sealing content under an operator-held key protects a stolen dump and
nothing else — the claim EXIT-006 exists to keep off the page.

So the boolean stays `false` and stops being stated. `OpenAgents.Forge.AtRest`
answers it from the columns that rest as plaintext, and the proof reads
`information_schema` for its population, so a migration that adds a plaintext
token column fails the day it lands rather than the day someone looks. The
sealed columns are checked against PostgreSQL with raw SQL after a real write,
not against the vaults' own unit tests.

Four mutations were confirmed red and reverted: a removed classification, a
digest reclassified as a plaintext secret, a vault made to return its
plaintext, and this module reverted to a literal — which comparing the two
values cannot catch, since `false` is the correct answer today, so the proof
reads the compiled import table the way `export_recipient_encryption` does.

`docs/2026-08-25-encryption-at-rest.md` records the threat model, what an
account would give up under an account-held key, and five rejected options.
#193 stays open: encryption did not land, and `/status` keeps publishing
`false`, which is the outcome EXIT-006 exists to produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 385 · 2026-08-25T14:48:14.748183Z

Changed files

  • modified INVARIANTS.md
  • added docs/2026-08-25-encryption-at-rest.md
  • added lib/openagents/forge/at_rest.ex
  • modified lib/openagents/forge/independence.ex
  • added test/openagents/forge/at_rest_test.exs

Diff

5 files changed, +836 -19

INVARIANTS.md modified +61 -12

@@ -3479,10 +3479,19 @@ window of unclaimed pairings, which retry; the recording vault has no keyring,

3479 3479
so rotating its key strands prior recordings — a bounded, recorded loss, not a
3480 3480
silent one.
3481 3481
3482
Amended 2026-08-25 (issue #193). `OpenAgents.Forge.AtRest.sealed_columns/0`
3483
names the column each of these three vaults seals, and
3484
`test/openagents/forge/at_rest_test.exs` reads each column back with raw SQL
3485
after a real write, so "sealed" is checked against PostgreSQL rather than
3486
against the vault's own unit tests. A fourth vault would have to appear there
3487
before `EXIT-006` could count it.
3488
3482 3489
Evidence: `OpenAgents.Machines.TokenVault`, `OpenAgents.Accounts.TokenVault`,
3483
`OpenAgents.Voice.RecordingVault`, `OpenAgents.RuntimeConfig.validate/1`,
3490
`OpenAgents.Voice.RecordingVault`, `OpenAgents.Forge.AtRest`,
3491
`OpenAgents.RuntimeConfig.validate/1`,
3484 3492
`config/runtime.exs`, `test/openagents/machines/token_vault_test.exs`,
3485
`test/openagents/accounts/token_vault_test.exs`, and
3493
`test/openagents/accounts/token_vault_test.exs`,
3494
`test/openagents/forge/at_rest_test.exs`, and
3486 3495
`test/openagents/runtime_config_test.exs`.
3487 3496
3488 3497
### RELEASE-003 — Every published hostname can establish LiveView

@@ -4809,11 +4818,40 @@ document: no parameter can widen it. `operator_reads_source` is derived from

4809 4818
and publishing the encryption without it would let a reader conclude the
4810 4819
operator cannot read an export.
4811 4820
4812
`encrypted_at_rest` is the one value still stated rather than derived, and it
4813
says so: no Ecto column in this repository is encrypted at rest, which issue
4814
#193 carries. There is no registry of encrypted columns to count, and inventing
4815
one so a number could appear would be the kind of claim this disclosure exists
4816
to prevent.
4821
`encrypted_at_rest` is derived too, from `OpenAgents.Forge.AtRest`: the private
4822
store is encrypted at rest exactly when no private column rests as plaintext.
4823
The value has not moved and is not expected to — it is `false`, and #193 stays
4824
open — but it can now fail, which as a literal it could not.
4825
4826
The earlier wording of this entry said no Ecto column in this repository is
4827
encrypted at rest. Three are: `users.github_token_ciphertext`,
4828
`machine_pairings.token_ciphertext`, and `voice_recording_chunks.data`, each
4829
under the separate key `VAULT-001` binds. The sentence understated the vaults
4830
and, worse, was a quantified claim about columns with no population behind it,
4831
which is the failure this ledger's own preamble names.
4832
4833
The registry that entry declined to invent is still declined. What replaced it
4834
is the smaller thing the preamble asks for: a population `information_schema`
4835
supplies. Every column carrying secret-shaped vocabulary is classified, no
4836
column may be classified `:plaintext_secret`, and the proof reads the catalog
4837
rather than a list, so a migration that adds a plaintext token column fails the
4838
day it lands rather than the day someone looks. The counts stay off the status
4839
page; only the boolean is published, and it is published because it can be
4840
wrong.
4841
4842
The derivation fails downward. `plaintext_private_columns/0` is a floor, so a
4843
private column missing from it leaves the boolean at `false` — where it already
4844
is — and an incomplete list understates the store instead of flattering it.
4845
That is the direction every other gather in this projection fails in.
4846
4847
Amended 2026-08-25 (issue #193). The decision about which columns stop being
4848
server-readable is recorded in `docs/2026-08-25-encryption-at-rest.md` with its
4849
threat model, the cost to an account under an account-held key, and five
4850
rejected options. No content column is encrypted, and the reason is that an
4851
operator-held key protects a stolen dump and nothing else — the claim `EXIT-006`
4852
exists to keep off this page — while an account-held key ends search,
4853
rendering, and the `TRANSPARENCY-001` projections, and makes key loss permanent.
4854
The published key set did not change, so `STATUS-001` has nothing to move.
4817 4855
4818 4856
Amended 2026-08-24 (issue #178). The decision that a private export can be
4819 4857
encrypted to a key the operator does not hold is recorded in

@@ -4843,12 +4881,23 @@ plaintext-store assertion catches once an anchor is configured; and hardcoding

4843 4881
route, which the derivation assertion catches even though the projection does
4844 4882
not.
4845 4883
4846
Evidence: `OpenAgents.Forge.Independence`, `OpenAgents.NetworkStatus`,
4884
Four more were confirmed and reverted for the at-rest derivation: a removed
4885
classification, standing in for a migration that adds an unclassified column;
4886
`api_tokens.token_digest` reclassified as a plaintext secret; a vault made to
4887
return its plaintext, which the raw-column read catches; and this module
4888
reverted to a literal `false`, which comparing the two values cannot catch —
4889
`false` is the correct answer today — so the proof reads the compiled import
4890
table the way `export_recipient_encryption` already does.
4891
4892
Evidence: `OpenAgents.Forge.Independence`, `OpenAgents.Forge.AtRest`,
4893
`OpenAgents.NetworkStatus`,
4847 4894
`OpenAgentsWeb.NetworkStatusLive`, `OpenAgents.DataRights.Age`,
4848 4895
`test/openagents/forge/independence_disclosure_test.exs`,
4896
`test/openagents/forge/at_rest_test.exs`,
4849 4897
`test/openagents/data_rights/age_test.exs`,
4850
`docs/forge-operator-independence.md`, and
4851
`docs/2026-08-24-private-export-encryption.md`.
4898
`docs/forge-operator-independence.md`,
4899
`docs/2026-08-24-private-export-encryption.md`, and
4900
`docs/2026-08-25-encryption-at-rest.md`.
4852 4901
4853 4902
### STACK-001 — A pull request stack is a durable object, not inferred topology
4854 4903

@@ -5578,7 +5627,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5578 5627
| OBSERVABILITY-001 | `test/openagents/observability_test.exs` |
5579 5628
| RELEASE-001 | `ops/ci/release-smoke.sh`, `test/openagents_web/controllers/health_controller_test.exs` |
5580 5629
| RELEASE-002 | `test/openagents/github_oauth/runtime_config_test.exs`, `ops/ci/reference-check.sh` |
5581
| VAULT-001 | `test/openagents/machines/token_vault_test.exs`, `test/openagents/runtime_config_test.exs` |
5630
| VAULT-001 | `test/openagents/machines/token_vault_test.exs`, `test/openagents/forge/at_rest_test.exs`, `test/openagents/runtime_config_test.exs` |
5582 5631
| RELEASE-003 | `lib/openagents/runtime_config.ex`, `config/runtime.exs`, `test/openagents/runtime_config_test.exs` |
5583 5632
| RELEASE-004 | `ops/ci/gate.sh`, `test/openagents/forge/gate_receipt_test.exs`, `test/openagents/hosted_ci_absence_test.exs` |
5584 5633
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/relup_node_test.exs`, `test/openagents/release/appup_test.exs`, `test/openagents/cluster/code_change_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |

@@ -5599,7 +5648,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5599 5648
| EXIT-003 | `test/openagents/forge/independence_test.exs` |
5600 5649
| EXIT-004 | `test/openagents/forge/independence_test.exs` |
5601 5650
| EXIT-005 | `test/openagents/forge/independence_test.exs`, `test/openagents/forge/wal_test.exs`, `test/openagents/forge/git_http_test.exs`, `test/openagents_web/controllers/push_receipt_controller_test.exs`, `test/openagents_web/controllers/forge_anchor_controller_test.exs` |
5602
| EXIT-006 | `test/openagents/forge/independence_disclosure_test.exs`, `test/openagents/data_rights/age_test.exs` |
5651
| EXIT-006 | `test/openagents/forge/independence_disclosure_test.exs`, `test/openagents/forge/at_rest_test.exs`, `test/openagents/data_rights/age_test.exs` |
5603 5652
| STACK-001 | `test/openagents/stacks_test.exs` |
5604 5653
| ISSUE-001 | `test/openagents/forge/commit_references_test.exs`, `test/openagents/issues/closing_references_test.exs`, `test/openagents/forge/push_closes_issues_test.exs` |
5605 5654
| FORUM-001 | `test/openagents/forum/legacy_surface_test.exs`, `test/openagents_web/live/forum_live_test.exs`, `test/openagents_web/route_authority_test.exs`, `test/openagents_web/sidebar_state_test.exs` |
docs/2026-08-25-encryption-at-rest.md added +197

@@ -0,0 +1,197 @@

1
# Which columns stop being server-readable, and what that would cost
2
3
**Date:** 2026-08-25
4
**Issue:** #193
5
**Parent:** #94
6
**Companion:** `docs/2026-08-24-private-export-encryption.md`
7
8
#178 asked whether a private export can be encrypted to a key the operator does
9
not hold. It can, and is. That decision deliberately left the other half open,
10
and #193 carries it: the store behind the export is plaintext PostgreSQL, and
11
`GET /api/status` publishes `independence.private_data.encrypted_at_rest` as
12
`false` so a reader who sees the export encryption also sees what it was built
13
from.
14
15
This document is the decision for the storage half.
16
17
## 1. The decision
18
19
**No content column is encrypted, the reason is written down rather than
20
implied, and the boolean that says so stops being a literal.**
21
22
Three things change:
23
24
- `encrypted_at_rest` is derived from `OpenAgents.Forge.AtRest` instead of
25
  being stated in `OpenAgents.Forge.Independence`. The private store is
26
  encrypted at rest exactly when no private column rests as plaintext, and the
27
  columns that do are named and proven plaintext against PostgreSQL.
28
- Every column whose name carries secret-shaped vocabulary is classified, and
29
  the population comes from `information_schema` rather than from a list.
30
  A migration that adds a plaintext token column fails on the day it lands.
31
- The ledger's claim is corrected. `EXIT-006` said "no Ecto column in this
32
  repository is encrypted at rest." Three are, and saying otherwise
33
  understated what the vaults already do while overstating how checkable the
34
  sentence was.
35
36
The published boolean does not move. It is still `false`, and it is still
37
`degraded?`'s at-rest axis. What moves is that it can now fail.
38
39
## 2. What is actually in the store
40
41
The inventory came first, because "encrypt the sensitive columns" is not a
42
decision until someone says which columns those are. Every migration in
43
`priv/repo/migrations/` was read, and every column the catalog reports under
44
secret-shaped vocabulary was classified. The result is narrower than the
45
issue's framing suggested.
46
47
**Reversible secret material rests in three columns, and all three are
48
sealed.** `users.github_token_ciphertext` under `OpenAgents.Accounts.TokenVault`,
49
`machine_pairings.token_ciphertext` under `OpenAgents.Machines.TokenVault`, and
50
`voice_recording_chunks.data` under `OpenAgents.Voice.RecordingVault`. Each has
51
its own key, which is what `VAULT-001` binds.
52
53
**Every other bearer credential rests as a one-way SHA-256 digest.** Personal
54
access tokens, agent tokens, computer tokens, inference grants, assignment
55
credentials, deployment workflow grants, device-flow codes, pairing codes, and
56
poll secrets. Nothing reverses these, so nothing seals them.
57
58
**External provider credentials never enter PostgreSQL at all.**
59
`scv_driver_accounts.secret_ref` is a pointer into Secret Manager;
60
`deployment_environments.secret_references` holds variable names.
61
`reputation_signing_keys.public_key` is the public half, and `RELEASE-002`
62
keeps the private half runtime-only.
63
64
So there was no plaintext secret column to encrypt. The gap #193 names is real,
65
but it is a gap in *content*, not in credentials.
66
67
## 3. The threat model, and why an operator key does not close it
68
69
Two designs are available and they protect against different adversaries. The
70
difference is the whole decision.
71
72
**A key the operator holds** protects a stolen dump, a stolen backup, and a
73
stolen disk. It protects against nothing else, because the operator decrypts at
74
will. This is exactly what the three vaults do, and `VOICE-012` already says it
75
in as many words: "the seal here defends against a stolen database, not against
76
the person who holds the key."
77
78
**A key only the account holds** protects against the operator. It also ends
79
every server-side read of that column.
80
81
Extending the first design across content columns would produce a status page
82
that says `encrypted_at_rest: true` while `operator_reads_source` is also true,
83
and a reader would take the pair to mean more than it does. `EXIT-006` exists to
84
keep that claim off the page. #178 rejected the same move on the export path for
85
the same reason, and rejecting it here is consistent rather than novel.
86
87
## 4. What the account would give up
88
89
Under an account-held key, for the columns that matter:
90
91
- **`messages.content`.** The conversation. It carries a `search_vector`;
92
  full-text search over your own history ends. So does server-side rendering,
93
  so does every projection that reads a message.
94
- **`voice_transcript_items.content`.** `VOICE-012` calls this the conversation
95
  record and says a recording never displaces it. Sealing the audio while this
96
  rests in plaintext beside it is worth naming as an asymmetry — a stolen dump
97
  gets the words either way — but sealing the transcript alone closes nothing,
98
  because `messages.content` holds the same words and is searched.
99
- **`issues.body` and `comments.body`.** Issue lists, search, cross-references,
100
  and the per-repository projections `TRANSPARENCY-001` publishes.
101
102
And key loss becomes permanent data loss. `docs/2026-08-24-private-export-encryption.md`
103
section 3 is the reason that objection decides storage and does not touch
104
exports: an export is derived, so a lost key costs one repeated download.
105
PostgreSQL is the record. There is nothing to re-derive it from.
106
107
That is the trade, stated plainly: an account that wants its issues encrypted
108
to a key this forge cannot read is asking for a forge that cannot list its
109
issues. Nobody has asked for that, and building it without being asked would
110
be choosing the cryptography over the product.
111
112
## 5. Options rejected
113
114
**Encrypt every content column under an operator-held key.** Rejected. It
115
protects a stolen backup, which the disk already does, and publishing it as
116
`encrypted_at_rest: true` would read as protection from the operator while
117
providing none. Same rejection as #178, same reason.
118
119
**Encrypt every content column under an account-held key.** Rejected, for now
120
and with the cost written down rather than waved at. It ends search, rendering,
121
and the transparency projections, and makes key loss permanent. A serious
122
version needs someone to ask for it and to accept section 4.
123
124
**Add `cloak` / `cloak_ecto` and an `Ecto.Type` per field.** Rejected on
125
mechanism as well as on threat model. This repository already has three vaults
126
with versioned framing, key identifiers, per-vault AAD, and a keyring for
127
rotation — `VAULT-001` binds the property that rotating one never unreads
128
another. `cloak_ecto`'s default is a single global keyring, which is the shape
129
`#192` found and fixed. Adding a dependency to get a weaker version of what is
130
already here would trade a proven property for a familiar name.
131
132
**Seal `voice_transcript_items.content` alone, since the audio beside it is
133
sealed.** Rejected, and it was the closest call. The asymmetry is real, but the
134
same words rest in `messages.content`, which is searched. Sealing one and not
135
the other would move a number without moving the threat model, which is the
136
failure mode this whole document is written against. It is recorded in section
137
4 rather than closed.
138
139
**Transparent disk encryption, and claim it.** Not rejected as a practice —
140
rejected as a claim. It protects a stolen disk and is invisible to this
141
repository, so nothing here can derive it and `EXIT-006` will not publish what
142
it cannot check.
143
144
## 6. What was proven, and how
145
146
`test/openagents/forge/at_rest_test.exs`.
147
148
The claim is not "the vaults have unit tests" — they did, and the columns were
149
still never checked. It is that PostgreSQL holds what this ledger says it
150
holds, so three kinds of assertion carry it:
151
152
1. **The sealed columns are ciphertext in the database.** Each of the three is
153
   written through the real application path — an OAuth token stored, a pairing
154
   approved, an audio slice appended — and then read back with raw SQL rather
155
   than through Ecto, because the schema's type layer is exactly what would
156
   hide the answer. The application still reads each value back, so the seal is
157
   a seal and not a loss.
158
2. **The plaintext columns are plaintext in the database.** The same read,
159
   expecting the opposite answer, so a column that quietly became sealed stops
160
   being published as a gap in the same commit.
161
3. **The population comes from `information_schema`.** Every secret-shaped
162
   column the catalog reports must be classified, and no column may be
163
   classified `:plaintext_secret`. That second assertion is the security
164
   contract; the first is what keeps it from going green on a population
165
   someone curated.
166
167
Four mutations were confirmed red and reverted:
168
169
- A classification removed, standing in for a migration that adds an
170
  unclassified column. The catalog-derived population caught it.
171
- `api_tokens.token_digest` reclassified `:plaintext_secret`. The security
172
  assertion caught it.
173
- `OpenAgents.Forge.Independence` reverted to a literal `false`. Comparing the
174
  two values **cannot** catch this, because `false` is the correct answer
175
  today, so the proof reads the compiled import table instead — the same
176
  technique `EXIT-002`, `EXIT-003`, and `export_recipient_encryption` use.
177
  This is the mutation that decided the shape of the test.
178
- `OpenAgents.Accounts.TokenVault.seal_with_metadata/1` made to return the
179
  plaintext. The raw-column assertion caught it.
180
181
One deliberate non-bite is worth recording. Shortening
182
`plaintext_private_columns/0` does not fail, and should not: the list is a
183
floor. A missing entry leaves `encrypted_at_rest?/0` at `false`, which is where
184
it already is, so an incomplete list understates the store rather than
185
flattering it. Completeness would only be load-bearing for a `true` claim, and
186
the first entry to disappear from that list will be the one that has to prove
187
it earned it.
188
189
## 7. What is still open
190
191
- **Content columns are plaintext, and the operator reads them.** That is the
192
  decision, not a gap in it. #193 stays open because the acceptance criteria
193
  say it does: encryption did not land, and `/status` keeps publishing `false`.
194
- **No operator read is audited.** `ADMIN-001`. An access log the operator
195
  writes into the operator's own database is evidence to the operator and to
196
  nobody else; #151 and #168 carry the external anchor.
197
- **The transcript/audio asymmetry.** Section 4. Recorded, not closed.
lib/openagents/forge/at_rest.ex added +217

@@ -0,0 +1,217 @@

1
defmodule OpenAgents.Forge.AtRest do
2
  @moduledoc """
3
  Which columns rest as ciphertext, which rest as plaintext, and which rest as
4
  something that was never a secret.
5
6
  `EXIT-006` published `encrypted_at_rest` as a literal `false` and said so:
7
  there was no registry of encrypted columns to count, and inventing one so a
8
  number could appear would be the claim that disclosure exists to prevent.
9
  This module is not that registry. It is the smaller thing the rule actually
10
  asks for — a population the database supplies, so a quantified claim about
11
  columns can fail.
12
13
  Three facts are kept apart here because collapsing them is how a store starts
14
  claiming more than it holds.
15
16
  `sealed_columns/0` names the columns that hold reversible secret material and
17
  the vault that seals each one. It is a floor, not a boast: sealing is under
18
  keys the operator holds, so it defends against a stolen dump and against
19
  nothing else, which `VAULT-001` and `VOICE-012` already say in their own
20
  terms.
21
22
  `plaintext_private_columns/0` names columns that hold private, user-authored
23
  content and rest as plaintext. Its entries are proven plaintext by reading
24
  the raw column back through SQL, so the list cannot claim a gap that closed.
25
26
  `encrypted_at_rest?/0` is the boolean `EXIT-006` publishes, and it is now
27
  derived: the private store is encrypted at rest exactly when no private
28
  column rests as plaintext. The derivation only ever *lowers* the claim. A
29
  non-empty plaintext list can produce nothing but `false`, so an incomplete
30
  list understates the store rather than flattering it, which is the direction
31
  every other gather in `OpenAgents.Forge.Independence` fails in.
32
33
  The load-bearing half is `classification/2`. Every column whose name carries
34
  secret-shaped vocabulary is classified, and the proof derives that population
35
  from `information_schema` rather than from this file, so a migration that
36
  adds a plaintext token column fails here on the day it lands instead of on
37
  the day someone remembers to look. `:plaintext_secret` exists as a
38
  classification and no column carries it; that assertion is the security
39
  contract, not the count beside it.
40
41
  Issue #193. `docs/2026-08-25-encryption-at-rest.md` records the decision.
42
  """
43
44
  @typedoc "Where a secret-shaped column's contents actually rest."
45
  @type classification ::
46
          :sealed
47
          | :digest
48
          | :reference
49
          | :metadata
50
          | :not_secret
51
          | :plaintext_secret
52
53
  @typedoc "One sealed column and the vault that seals it."
54
  @type sealed :: %{
55
          table: String.t(),
56
          column: String.t(),
57
          vault: module(),
58
          holds: String.t()
59
        }
60
61
  @typedoc "One private column that rests as plaintext."
62
  @type plaintext :: %{table: String.t(), column: String.t(), holds: String.t()}
63
64
  # The regular expression the proof hands to `information_schema` to build the
65
  # population it checks this module against. It lives here so the module and
66
  # its proof cannot disagree about which columns are in scope.
67
  @secret_shaped_pattern "(token|secret|credential|password|passphrase|api_key|private_key|mnemonic|seed|nsec|cipher|sealed)"
68
69
  @sealed [
70
    %{
71
      table: "users",
72
      column: "github_token_ciphertext",
73
      vault: OpenAgents.Accounts.TokenVault,
74
      holds: "delegated GitHub access token"
75
    },
76
    %{
77
      table: "machine_pairings",
78
      column: "token_ciphertext",
79
      vault: OpenAgents.Machines.TokenVault,
80
      holds: "computer token awaiting its pairing claim"
81
    },
82
    %{
83
      table: "voice_recording_chunks",
84
      column: "data",
85
      vault: OpenAgents.Voice.RecordingVault,
86
      holds: "one slice of uploaded call audio"
87
    }
88
  ]
89
90
  # Named rather than enumerated, and the reason is the direction of the error.
91
  # A private column missing from this list leaves `encrypted_at_rest?/0` at
92
  # `false`, which is where it already is; a column named here that turns out
93
  # to be sealed turns the proof red. Both failures understate the store.
94
  @plaintext_private [
95
    %{table: "messages", column: "content", holds: "conversation messages"},
96
    %{
97
      table: "voice_transcript_items",
98
      column: "content",
99
      holds: "the voice conversation record VOICE-012 calls authority"
100
    },
101
    %{table: "issues", column: "body", holds: "issue bodies"},
102
    %{table: "comments", column: "body", holds: "issue and pull request comments"}
103
  ]
104
105
  # Every column the catalog reports under `secret_shaped_pattern/0`. The proof
106
  # derives that population from `information_schema` and fails on anything
107
  # this map does not answer for, so the map cannot fall behind a migration.
108
  @classifications %{
109
    # Reversible secret material, sealed under a vault key.
110
    {"users", "github_token_ciphertext"} => :sealed,
111
    {"machine_pairings", "token_ciphertext"} => :sealed,
112
113
    # One-way SHA-256 of a bearer credential, unique-indexed because it is the
114
    # lookup key. Nothing reverses these, so nothing seals them.
115
    {"agent_tokens", "token_digest"} => :digest,
116
    {"api_tokens", "token_digest"} => :digest,
117
    {"deployment_workflow_grants", "token_digest"} => :digest,
118
    {"forge_assignment_credentials", "token_digest"} => :digest,
119
    {"inference_grants", "token_digest"} => :digest,
120
    {"machine_pairings", "poll_secret_digest"} => :digest,
121
    {"machines", "token_digest"} => :digest,
122
123
    # A pointer to secret material held somewhere else, an algorithm name, or a
124
    # public half. No secret rests in the column.
125
    {"deployment_environments", "secret_references"} => :reference,
126
    {"portable_export_receipts", "cipher_id"} => :reference,
127
    {"reputation_signing_keys", "public_key"} => :reference,
128
    {"scv_driver_accounts", "credential_kind"} => :reference,
129
    {"scv_driver_accounts", "credential_version"} => :reference,
130
    {"scv_driver_accounts", "secret_ref"} => :reference,
131
132
    # Lifecycle sidecars for a secret that rests elsewhere: which key sealed
133
    # it, when it was issued, whether it is sealed at all.
134
    {"device_authorizations", "api_token_id"} => :metadata,
135
    {"forge_assignments", "credential_delivery_reason"} => :metadata,
136
    {"forge_assignments", "credential_delivery_status"} => :metadata,
137
    {"machines", "scoped_forge_credentials_enabled"} => :metadata,
138
    {"machines", "token_expires_at"} => :metadata,
139
    {"users", "github_token_connected_at"} => :metadata,
140
    {"users", "github_token_key_id"} => :metadata,
141
    {"users", "github_token_rotated_at"} => :metadata,
142
    {"users", "github_token_scopes"} => :metadata,
143
    {"voice_recordings", "sealed"} => :metadata,
144
145
    # The vocabulary matched and the meaning did not: deduplication keys,
146
    # identity keys, and token counters carry no secret.
147
    {"box_runs", "idempotency_key"} => :not_secret,
148
    {"compensation_events", "invocation_key"} => :not_secret,
149
    {"compensation_outcome_decisions", "invocation_key"} => :not_secret,
150
    {"deployment_requests", "idempotency_key"} => :not_secret,
151
    {"effects", "idempotency_key"} => :not_secret,
152
    {"forum_posts", "idempotency_key"} => :not_secret,
153
    {"forum_tip_intents", "idempotency_key"} => :not_secret,
154
    {"forum_topics", "idempotency_key"} => :not_secret,
155
    {"graph_artifacts", "conflict_key"} => :not_secret,
156
    {"graph_artifacts", "identity_key"} => :not_secret,
157
    {"graph_artifacts", "version_key"} => :not_secret,
158
    {"gym_runs", "input_tokens"} => :not_secret,
159
    {"gym_runs", "output_tokens"} => :not_secret,
160
    {"inference_grants", "max_total_tokens"} => :not_secret,
161
    {"namespace_aliases", "slug_key"} => :not_secret,
162
    {"namespaces", "slug_key"} => :not_secret,
163
    {"notifications", "dedupe_key"} => :not_secret,
164
    {"preferences", "effect_key"} => :not_secret,
165
    {"pull_request_stack_idempotency_requests", "idempotency_key"} => :not_secret,
166
    {"repositories", "name_key"} => :not_secret,
167
    {"repositories", "owner_key"} => :not_secret,
168
    {"repositories", "storage_key"} => :not_secret,
169
    {"repository_idempotency_requests", "idempotency_key"} => :not_secret,
170
    {"repository_publications", "idempotency_key"} => :not_secret,
171
    {"settlement_payment_intents", "idempotency_key"} => :not_secret,
172
    {"stack_operations", "idempotency_key"} => :not_secret,
173
    {"turn_tool_steps", "billable_attribution_key"} => :not_secret,
174
    {"turn_tool_steps", "invocation_key"} => :not_secret
175
  }
176
177
  @doc """
178
  Whether the private store is encrypted at rest.
179
180
  True exactly when no private column rests as plaintext, which is what
181
  `EXIT-006` publishes and `OpenAgents.Forge.Independence` derives
182
  `operator_reads_source` from.
183
  """
184
  @spec encrypted_at_rest?() :: boolean()
185
  def encrypted_at_rest?, do: Enum.empty?(plaintext_private_columns())
186
187
  @doc "Columns that hold reversible secret material, and the vault sealing each."
188
  @spec sealed_columns() :: [sealed()]
189
  def sealed_columns, do: @sealed
190
191
  @doc "Private, user-authored columns that rest as plaintext."
192
  @spec plaintext_private_columns() :: [plaintext()]
193
  def plaintext_private_columns, do: @plaintext_private
194
195
  @doc """
196
  Where a secret-shaped column's contents rest, or `nil` when unclassified.
197
198
  An unclassified column is a proof failure rather than a silent `:not_secret`:
199
  the point is that a new one cannot pass unnoticed.
200
  """
201
  @spec classification(String.t(), String.t()) :: classification() | nil
202
  def classification(table, column) when is_binary(table) and is_binary(column),
203
    do: Map.get(@classifications, {table, column})
204
205
  @doc "Every classified column, as `{{table, column}, classification}` pairs."
206
  @spec classifications() :: %{{String.t(), String.t()} => classification()}
207
  def classifications, do: @classifications
208
209
  @doc """
210
  The PostgreSQL regular expression that selects secret-shaped column names.
211
212
  The proof hands this to `information_schema` so the population it checks is
213
  the database's answer rather than this module's.
214
  """
215
  @spec secret_shaped_pattern() :: String.t()
216
  def secret_shaped_pattern, do: @secret_shaped_pattern
217
end
lib/openagents/forge/independence.ex modified +14 -7

@@ -32,10 +32,13 @@ defmodule OpenAgents.Forge.Independence do

32 32
    export can be encrypted to a key the recipient holds (#178), and that is
33 33
    derived: `OpenAgentsWeb.DataController`'s compiled import table either
34 34
    reaches `OpenAgents.DataRights.Age` or it does not, so removing the
35
    encryption removes the claim in the same commit. Nothing in PostgreSQL is
36
    encrypted at rest, and that one is stated, because there is no registry of
37
    encrypted columns to count and inventing one to make a number appear would
38
    be the kind of claim this disclosure exists to avoid; #193 carries it.
35
    encryption removes the claim in the same commit. The private store is not
36
    encrypted at rest, and that one is now derived too, from
37
    `OpenAgents.Forge.AtRest`: the store is encrypted exactly when no private
38
    column rests as plaintext, and the columns that do are named and proven
39
    plaintext against the database rather than asserted here. The derivation
40
    can only lower the claim, so an incomplete list understates the store
41
    instead of flattering it; #193 carries what is left.
39 42
    `operator_reads_source` is derived from the second fact rather than
40 43
    restated, because it is the same fact: the operator reads the plaintext an
41 44
    export is built from exactly while the store is plaintext. Publishing the

@@ -53,6 +56,7 @@ defmodule OpenAgents.Forge.Independence do

53 56
54 57
  alias OpenAgents.DataRights.ExportInventory
55 58
  alias OpenAgents.Forge.Anchor
59
  alias OpenAgents.Forge.AtRest
56 60
57 61
  @schema "openagents.forge_independence.v1"
58 62

@@ -152,10 +156,13 @@ defmodule OpenAgents.Forge.Independence do

152 156
    }
153 157
  end
154 158
155
  # `encrypted_at_rest` is the one stated value left here, and everything
156
  # around it is read from the code rather than asserted beside it.
159
  # `encrypted_at_rest` used to be the one stated value left here. It is now
160
  # derived from `OpenAgents.Forge.AtRest`, which answers it from the columns
161
  # that rest as plaintext rather than from a literal beside the disclosure. A
162
  # failed read answers `false`, the same direction every other gather fails
163
  # in: the store is claimed to be less protected than it is, never more.
157 164
  defp private_data_section do
158
    encrypted_at_rest? = false
165
    encrypted_at_rest? = safely(fn -> AtRest.encrypted_at_rest?() end) || false
159 166
160 167
    %{
161 168
      "export_recipient_encryption" => export_recipient_encryption?(),
test/openagents/forge/at_rest_test.exs added +347

@@ -0,0 +1,347 @@

1
defmodule OpenAgents.Forge.AtRestTest do
2
  @moduledoc """
3
  EXIT-006, VAULT-001, issue #193.
4
5
  `encrypted_at_rest` was a literal `false` on the status page. A literal
6
  cannot fail, so it said nothing about the store — not even the part that was
7
  true, which is that three columns do rest as ciphertext.
8
9
  These are the assertions that make the boolean mean something. Two of them
10
  read raw columns back through SQL rather than through Ecto, because a schema
11
  that loads a value through a type is exactly the layer that would hide the
12
  answer: the question is what PostgreSQL holds, so PostgreSQL is asked.
13
14
  The third is the one worth keeping. The population of secret-shaped columns
15
  comes from `information_schema`, so a migration that adds a plaintext token
16
  column fails here on the day it lands. A test of the columns someone thought
17
  of cannot fail on the column they did not.
18
  """
19
20
  use OpenAgents.DataCase, async: false
21
22
  alias OpenAgents.Accounts
23
  alias OpenAgents.Conversations
24
  alias OpenAgents.Forge.AtRest
25
  alias OpenAgents.Machines
26
  alias OpenAgents.Repo
27
  alias OpenAgents.Voice
28
  alias OpenAgents.Voice.Config
29
  alias OpenAgents.Voice.TranscriptItem
30
31
  describe "the sealed columns rest as ciphertext" do
32
    test "a GitHub access token is not readable in its own column" do
33
      {:ok, user} = Accounts.upsert_github_user(github_profile("at-rest-github"))
34
      token = "gho_at_rest_#{System.unique_integer([:positive])}"
35
36
      assert {:ok, connected} = Accounts.store_github_token(user, token)
37
38
      # The application still reads it, so the seal is a seal and not a loss.
39
      assert {:ok, ^token} = Accounts.github_token(connected)
40
41
      stored = raw_column("users", "github_token_ciphertext", connected.id)
42
43
      assert is_binary(stored) and byte_size(stored) > 0,
44
             "the fixture must actually store something"
45
46
      refute contains?(stored, token),
47
             "users.github_token_ciphertext holds the token PostgreSQL was supposed to hide"
48
    end
49
50
    test "a pairing token is not readable in its own column" do
51
      {:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} =
52
        Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
53
54
      {:ok, _machine} = Machines.approve_pairing(github_user("at-rest-pairing"), code)
55
56
      stored = raw_column("machine_pairings", "token_ciphertext", pairing.id)
57
58
      assert is_binary(stored) and byte_size(stored) > 0,
59
             "the fixture must actually hold a sealed token"
60
61
      # The claim returns the plaintext exactly once, which is the only reader.
62
      assert {:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
63
64
      refute contains?(stored, token),
65
             "machine_pairings.token_ciphertext holds the token it was supposed to seal"
66
    end
67
68
    test "a call audio slice is not readable in its own column" do
69
      audio = "opus-bytes-#{System.unique_integer([:positive])}"
70
      session = admitted_voice_session("at-rest-audio")
71
72
      assert {:ok, recording} =
73
               Voice.Recordings.append_chunk(
74
                 session,
75
                 session.generation,
76
                 1,
77
                 audio,
78
                 "audio/webm;codecs=opus"
79
               )
80
81
      assert recording.sealed
82
      assert {:ok, ^audio} = Voice.Recordings.read(recording)
83
84
      %{rows: [[stored]]} =
85
        Repo.query!(
86
          "SELECT data FROM voice_recording_chunks WHERE voice_recording_id = $1 AND sequence = 1",
87
          [Ecto.UUID.dump!(recording.id)]
88
        )
89
90
      refute contains?(stored, audio),
91
             "voice_recording_chunks.data holds the audio VOICE-012 says is sealed"
92
    end
93
  end
94
95
  describe "the plaintext columns rest as plaintext" do
96
    # A ledger that names a gap has to be capable of being wrong about it.
97
    # These read the same way the sealed assertions do and expect the opposite
98
    # answer, so a column that quietly became sealed stops being published as
99
    # a gap in the same commit.
100
    test "every column plaintext_private_columns/0 names is plaintext in PostgreSQL" do
101
      for column <- AtRest.plaintext_private_columns() do
102
        {id, written} = write_private_row(column)
103
        stored = raw_column(column.table, column.column, id)
104
105
        assert contains?(stored, written),
106
               "#{column.table}.#{column.column} is named as plaintext but PostgreSQL " <>
107
                 "does not hold the plaintext. If it is sealed now, take it off the list."
108
      end
109
    end
110
  end
111
112
  describe "the population comes from the database" do
113
    test "every secret-shaped column the catalog reports is classified" do
114
      unclassified =
115
        for {table, column} <- secret_shaped_columns(),
116
            is_nil(AtRest.classification(table, column)),
117
            do: "#{table}.#{column}"
118
119
      assert unclassified == [],
120
             "These columns carry secret-shaped names and OpenAgents.Forge.AtRest does " <>
121
               "not say where their contents rest:\n  " <>
122
               Enum.join(unclassified, "\n  ") <>
123
               "\n\nClassify each one. If any holds reversible secret material in " <>
124
               "plaintext, seal it under a vault rather than classifying it away."
125
    end
126
127
    test "no column is classified as a plaintext secret" do
128
      # The security assertion. Everything else in this file exists to make
129
      # this one capable of failing.
130
      plaintext_secrets =
131
        for {{table, column}, :plaintext_secret} <- AtRest.classifications(),
132
            do: "#{table}.#{column}"
133
134
      assert plaintext_secrets == [],
135
             "Reversible secret material rests as plaintext in: " <>
136
               Enum.join(plaintext_secrets, ", ")
137
    end
138
139
    test "the ledger classifies nothing the catalog does not have" do
140
      catalog = MapSet.new(secret_shaped_columns())
141
142
      stale =
143
        for {{table, column}, _classification} <- AtRest.classifications(),
144
            not MapSet.member?(catalog, {table, column}),
145
            do: "#{table}.#{column}"
146
147
      assert stale == [],
148
             "OpenAgents.Forge.AtRest classifies columns PostgreSQL does not have: " <>
149
               Enum.join(stale, ", ")
150
    end
151
152
    test "every sealed column exists and names a vault that can seal and open" do
153
      for sealed <- AtRest.sealed_columns() do
154
        assert column_exists?(sealed.table, sealed.column),
155
               "#{sealed.table}.#{sealed.column} is named as sealed but does not exist"
156
157
        assert Code.ensure_loaded?(sealed.vault),
158
               "#{inspect(sealed.vault)} does not exist"
159
160
        assert function_exported?(sealed.vault, :seal, 1) or
161
                 function_exported?(sealed.vault, :seal, 3),
162
               "#{inspect(sealed.vault)} exports no seal/1 or seal/3"
163
164
        assert function_exported?(sealed.vault, :open, 1) or
165
                 function_exported?(sealed.vault, :open, 3),
166
               "#{inspect(sealed.vault)} exports no open/1 or open/3"
167
      end
168
    end
169
  end
170
171
  describe "the disclosure derives from this ledger" do
172
    test "encrypted_at_rest? is exactly whether the plaintext list is empty" do
173
      assert AtRest.encrypted_at_rest?() == Enum.empty?(AtRest.plaintext_private_columns())
174
    end
175
176
    test "the store is not encrypted at rest today, and the ledger says why" do
177
      refute AtRest.encrypted_at_rest?()
178
      assert length(AtRest.plaintext_private_columns()) > 0
179
    end
180
181
    test "the status projection publishes this value rather than a literal" do
182
      # EXIT-006 derives `encrypted_at_rest` from this module. If the
183
      # projection stops asking, this fails even though the published boolean
184
      # does not change, which is the mutation the old literal could not catch.
185
      section = OpenAgents.Forge.Independence.projection()["private_data"]
186
187
      assert section["encrypted_at_rest"] == AtRest.encrypted_at_rest?()
188
      assert section["operator_reads_source"] == not AtRest.encrypted_at_rest?()
189
    end
190
191
    test "the disclosure is compiled against this module, not against a literal" do
192
      # Comparing the two values cannot catch a revert to `false`, because
193
      # `false` is the answer today. The compiled import table can: the
194
      # projection either reaches this module or it does not. Same read
195
      # EXIT-006 already uses for `export_recipient_encryption`.
196
      assert AtRest in external_calls(OpenAgents.Forge.Independence),
197
             "EXIT-006 derives `encrypted_at_rest` from OpenAgents.Forge.AtRest. " <>
198
               "OpenAgents.Forge.Independence no longer calls it, so the published " <>
199
               "boolean is a literal again even though its value has not changed."
200
    end
201
  end
202
203
  defp external_calls(module) do
204
    case :beam_lib.chunks(:code.which(module), [:imports]) do
205
      {:ok, {^module, [imports: imports]}} -> Enum.map(imports, &elem(&1, 0))
206
      _unreadable -> []
207
    end
208
  end
209
210
  # ── reading PostgreSQL rather than Ecto ──────────────────────────────────
211
212
  defp raw_column(table, column, id) do
213
    %{rows: [[value]]} =
214
      Repo.query!("SELECT #{column} FROM #{table} WHERE id = $1", [dump_id(id)])
215
216
    value
217
  end
218
219
  # Primary keys here are UUIDs or integers depending on the table.
220
  defp dump_id(id) when is_integer(id), do: id
221
  defp dump_id(id) when is_binary(id), do: Ecto.UUID.dump!(id)
222
223
  defp contains?(nil, _needle), do: false
224
225
  defp contains?(haystack, needle) when is_binary(haystack) and is_binary(needle),
226
    do: :binary.match(haystack, needle) != :nomatch
227
228
  defp column_exists?(table, column) do
229
    %{rows: [[count]]} =
230
      Repo.query!(
231
        """
232
        SELECT count(*) FROM information_schema.columns
233
        WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
234
        """,
235
        [table, column]
236
      )
237
238
    count == 1
239
  end
240
241
  defp secret_shaped_columns do
242
    %{rows: rows} =
243
      Repo.query!(
244
        """
245
        SELECT c.table_name, c.column_name
246
        FROM information_schema.columns c
247
        JOIN information_schema.tables t
248
          ON t.table_schema = c.table_schema AND t.table_name = c.table_name
249
        WHERE c.table_schema = 'public'
250
          AND t.table_type = 'BASE TABLE'
251
          AND (c.column_name ~ $1 OR c.column_name LIKE '%\\_key')
252
        """,
253
        [AtRest.secret_shaped_pattern()]
254
      )
255
256
    Enum.map(rows, fn [table, column] -> {table, column} end)
257
  end
258
259
  # ── writing one real row per named plaintext column ──────────────────────
260
261
  defp write_private_row(%{table: "messages", column: "content"}) do
262
    content = "plaintext-message-#{System.unique_integer([:positive])}"
263
    {:ok, conversation} = Conversations.ensure_conversation("at-rest-message")
264
    {:ok, message} = Conversations.create_voice_context_message(conversation, content)
265
266
    {message.id, content}
267
  end
268
269
  defp write_private_row(%{table: "voice_transcript_items", column: "content"}) do
270
    # Voice creates these inside the sideband handler, so the insert goes
271
    # through the schema's own changeset here. That is the layer under test:
272
    # "no Ecto column is encrypted at rest" is a claim about the types a
273
    # schema declares, and this is the type layer answering.
274
    content = "plaintext-transcript-#{System.unique_integer([:positive])}"
275
    session = admitted_voice_session("at-rest-transcript")
276
277
    {:ok, item} =
278
      %TranscriptItem{}
279
      |> TranscriptItem.create_changeset(%{
280
        voice_session_id: session.id,
281
        generation: session.generation,
282
        provider_item_id: "item-#{System.unique_integer([:positive])}",
283
        role: "user",
284
        content: content,
285
        status: "final",
286
        observed_at: DateTime.utc_now()
287
      })
288
      |> Repo.insert()
289
290
    {item.id, content}
291
  end
292
293
  defp write_private_row(%{table: "issues", column: "body"}) do
294
    body = "plaintext-issue-#{System.unique_integer([:positive])}"
295
    repository = OpenAgents.AccountsFixtures.repository_fixture()
296
297
    {:ok, issue} =
298
      OpenAgents.Issues.create_issue(repository, %{title: "at rest", body: body})
299
300
    {issue.id, body}
301
  end
302
303
  defp write_private_row(%{table: "comments", column: "body"}) do
304
    body = "plaintext-comment-#{System.unique_integer([:positive])}"
305
    user = github_user("at-rest-comment")
306
    repository = OpenAgents.AccountsFixtures.repository_fixture()
307
    {:ok, issue} = OpenAgents.Issues.create_issue(repository, %{title: "at rest"})
308
    {:ok, comment} = OpenAgents.Issues.create_comment(issue, %{"body" => body}, user)
309
310
    {comment.id, body}
311
  end
312
313
  # ── fixtures ─────────────────────────────────────────────────────────────
314
315
  defp github_profile(key) do
316
    digest = :crypto.hash(:sha256, key)
317
318
    %{
319
      github_id: digest |> binary_part(0, 7) |> :binary.decode_unsigned(),
320
      github_login: "at-rest-#{Base.encode16(digest, case: :lower) |> binary_part(0, 10)}",
321
      github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
322
    }
323
  end
324
325
  defp github_user(key) do
326
    {:ok, user} = Accounts.upsert_github_user(github_profile(key))
327
    user
328
  end
329
330
  defp admitted_voice_session(key) do
331
    {:ok, conversation} = Conversations.ensure_conversation(key)
332
    {:ok, session} = Voice.admit_session(conversation, voice_config())
333
    session
334
  end
335
336
  defp voice_config do
337
    Config.build!(
338
      enabled: true,
339
      architecture: :openai_realtime,
340
      provider: "openai",
341
      model: "gpt-realtime-2.1",
342
      voice: "marin",
343
      reasoning_effort: "low",
344
      maximum_session_seconds: 3_000
345
    )
346
  end
347
end

This page updates live while a promote is in flight · changelog