Retention and metadata classification: retained, blocked, lost and unknown¶
| Date | 24 September 2026 |
| Work item | Experiment E5 - test retention and metadata classification |
| Design reference | §7.5 Rotation and retention contract, §7.6 Metadata-only dependency checks, §18.1 Phase 0 |
| Artifacts | experiments/e5-retention-classification/, evidence under experiments/e5-retention-classification/evidence/ |
| Decision enabled | Which metadata each candidate exposes for classifying a dependency, where that metadata runs out, and what alert policy the evidence supports. Input to deployment profile selection and retention and recovery policy. No provider, interval or policy is selected here. |
1. Question¶
Design §7.6 requires a provider to expose enough metadata, without secret values, to classify every secret or key version a release depends on as one of four states:
| State | §7.6 meaning |
|---|---|
| retained | present with no known retention block; says nothing about whether the caller can read or decrypt |
| blocked | present with a known reversible restriction (a soft delete, a decryption floor) |
| lost | evidence establishes irreversible removal from the current provider (destruction, pruning, trim) |
| unknown | the check could not be completed, or the metadata cannot tell the other states apart |
It adds three constraints. An absent listing, denied access or unreachable provider "is not by itself proof of loss". Unknown "never counts as a pass or becomes lost by timeout", and a persistent unknown is alertable after an interval the design leaves open. And the check "establishes retention status only": it neither authorizes dispatch nor replaces the point-of-use checks.
The issue asks for four things, each answered in §6: the mapping from metadata to state per candidate, the proof that nothing uncertain becomes lost, the proof that a retained verdict grants no authority, and the provider limits plus the alert policy the evidence supports.
2. Candidates and identities¶
The candidates are those of the provider-capability comparison:
- OpenBao 2.6.1 KV v2 and Transit, as the investigation fixtures run it (single node, integrated Raft, real init and unseal).
- The local age store, in the layout the provider-capability run built: one ciphertext per generation, with a metadata file beside it recording the recipient and the ciphertext's SHA-256.
- SOPS files (sops 3.13.3 with age 1.3.2, JSON format), whose metadata is the
sopsblock, readable without a key, including the MAC.
| Identity | Holds | Used for |
|---|---|---|
| metadata | the fixtures' metadata-only token: read and list on secret/metadata/* and transit/keys/* |
every classification |
| compiler | transit/encrypt/* |
encrypting the synthetic artifact |
| executor | transit/decrypt/* |
decrypting it |
| other | read on secret/data/other/* only: a compiler for another scope |
authority checks |
| admin | the root token, standing for the operator's administrator | setting states the fixture has no injection for; reading ground truth |
| operator | the fixtures' inject command |
soft delete, destroy, key deletion, partition, pause |
For the local stores, the classifier reads only the metadata file or sops block and the
ciphertext's digest. It never opens a key file.
3. What was built¶
3.1 Rules, classifier, rows¶
run/decide.sh holds the OpenBao rules and the monitor as pure functions over one provider answer.
run/test-decide checks them with 48 checks on synthetic answers written out in the script, before
any capture uses them (the first rules were written after their checks had been seen to fail
against a stub). run/classify asks the
provider and applies the rules. For OpenBao it sends one GET per dependency over HTTP to the
published port, from the host, with the metadata token, and refuses a name that is not a plain
OpenBao path. It does not use the fixtures' bao wrapper, which runs inside the container, where a
partition cannot be seen (fixtures report §7). A KV deletion time is compared with the provider's
own time for the same answer, its Date header, never with the client's clock (§5.6). The header
has whole seconds, so a deletion time in that same second could fall on either side and is unknown.
The rules:
| Candidate | Answer | Class |
|---|---|---|
| any | a version that is not a positive integer | unknown |
| KV v2 | version present, no deletion_time |
retained |
| KV v2 | deletion_time after the provider's time for the answer (delete_version_after) |
retained, reason names the scheduled time |
| KV v2 | deletion_time before the provider's time for the answer |
blocked (undelete reverses it) |
| KV v2 | deletion_time in the same second as the provider's time, or no provider time |
unknown |
| KV v2 | destroyed: true |
lost |
| KV v2 | version below oldest_version (when that is above 0) |
lost (pruned) |
| KV v2 | version above current_version, or missing with no recorded removal |
unknown |
| Transit | soft_deleted anything but false |
unknown (reversibility not observed) |
| Transit | version below min_available_version (when above 0) |
lost (trimmed) |
| Transit | version below min_decryption_version |
blocked |
| Transit | version above latest_version, or a floor missing from the answer |
unknown |
| Transit | otherwise | retained |
| age store | metadata readable, ciphertext digest as recorded | retained |
| age store | ciphertext digest differs from the recorded one | unknown (rewritten) |
| SOPS | MAC as referenced | retained |
| SOPS | another MAC | unknown (rewritten) |
| any | HTTP 403 or a file permission error | unknown (denied) |
| any | HTTP 404 or no such file | unknown (absent) |
| any | HTTP 503 | unknown (sealed or unavailable) |
| any | no connection, no answer in time, any other failure | unknown |
Transit's own keys map is not used. It hides versions below the decryption floor, as the
provider-capability run found, and it also hides trimmed versions (027: after the trim, the map
holds only version 2). The floors are what distinguish blocked from lost.
Neither local candidate can show lost. A changed ciphertext digest or SOPS MAC shows that the file was rewritten, not that the referenced value is gone, and §5.2 has rows where the value survived each.
A monitor (monitor in decide.sh) records each observation of a dependency. It prints the class
it was given and an alert: regression when a dependency seen retained turns unknown,
persistent once unknown has lasted RC_ALERT_AFTER seconds, and blocked or lost for those
classes. It never changes the class.
run/openbao and run/local put the dependencies into each state and record one row per
observation in verdicts.tsv: the expected class, written before the command runs; the observed
class and reason, taken from the classifier's last line; and the state it was observed in. A row
that differs from its expectation is a mismatch and the run carries on.
3.2 States and their ground truth¶
Every OpenBao state has its own fixtures/bin/evidence bundle, copied out of .state before the
next state is set. From each bundle, evidence/states/<state>/ keeps openbao-metadata.jsonl (the
provider's metadata, read inside the container), openbao-client-view.txt (whether the published
port answered) and versions.txt (the container's state and networks):
| State | Set by | Client view | Out-of-band metadata |
|---|---|---|---|
| baseline | populating KV and Transit | HTTP 200 | complete |
| removed | inject bao-soft-delete, bao-destroy, bao-delete-key; admin metadata delete, rotate + floor, trim |
HTTP 200 | complete |
| reversed | admin undelete, floor lowered; a trim reversal attempted | HTTP 200 | complete |
| partitioned | inject netsplit openbao |
curl exit 7 | complete; networks=[] |
| rejoined | inject netjoin openbao |
HTTP 200 | complete |
| paused | inject pause openbao |
curl exit 28 | unknown: nothing could be read |
| after-uncertain | a bao-destroy sent while paused, then unpause |
HTTP 200 | complete |
| sealed | admin bao operator seal |
HTTP 503 | unknown: nothing could be read |
| unsealed | the fixture's unseal | HTTP 200 | complete |
| authority | as unsealed, with the authority checks run in it | HTTP 200 | complete |
| final | after the local runs; its leak scan covers the local stores | HTTP 200 | complete |
The bundle's KV records carry each version's deletion_time and destroyed, but not
current_version or oldest_version. Its Transit records carry latest and min_decryption,
but not min_available_version. The admin rows read what the bundle lacks: 005 for the pruned
path, 028 for the trimmed key, 051 for the path the unanswered destroy was aimed at.
The local stores have no bundle, since fixtures/bin/evidence records OpenBao and not these files.
Their rows carry the state local-files. Their ground truth is the injection row before each
classification, which changed the files, and the read rows, which open the value with its key.
3.3 Pinned versions¶
OpenBao ghcr.io/openbao/openbao:2.6.1@sha256:5b2486ab…a67e0, sops v3.13.3 and age v1.3.2, all
from fixtures/versions.env, each verified by the fixture before use. The classifier's HTTP client
is the host's curl (8.14.1 here), not a pinned image: it has to be a client outside the container.
It needs curl 8.3 or later for --variable and --expand-header, which keep the token off the
command line.
3.4 Synthetic values and the leak scan¶
Every value is BWSYNTH-rc-<name>-<random hex>, generated per run under RC_OUT, and reaches a
tool only over stdin. Every value read back is reduced to a digest verdict before it reaches a
transcript. The final bundle's leak scan walked .state/data, where the local stores live, and
found its positive control and nothing else (summary.txt). run/collect-evidence refused
anything matching the fixture's scan patterns, an age secret key, an OpenBao token or a home path.
3.5 Reproduction¶
experiments/e5-retention-classification/run/test-decide
fixtures/bin/up
RC_OUT=<empty directory on disk> experiments/e5-retention-classification/run/all
RC_OUT=<the same directory> experiments/e5-retention-classification/run/collect-evidence
fixtures/bin/down
The committed capture took under three minutes after up: 107 rows, 0 mismatches, 48 of 48 rule
checks.
4. Results¶
Rows are numbered as in verdicts.tsv; each has its transcript under evidence/transcripts/.
4.1 OpenBao¶
| Dependency and state | Row | Provider answer | Class |
|---|---|---|---|
| KV version, live | 001 | version present, no deletion_time |
retained |
| KV version, deletion scheduled in an hour | 002 | deletion_time in the future |
retained |
| KV version 2 of a path written 11 times | 003 | present | retained |
| KV version 1 of that path | 004 | oldest_version: 2, current_version: 11 (005) |
lost |
| KV version 12 of that path | 006 | above current_version |
unknown |
| Transit key version 1, live | 007 | within both floors | retained |
| KV path on another mount | 008 | HTTP 403 | unknown |
| Transit key on another mount | 009 | HTTP 403 | unknown |
| KV path that never existed | 010 | HTTP 404 | unknown |
| KV version, soft-deleted | 021 | deletion_time in the past |
blocked |
| KV version, destroyed | 022 | destroyed: true |
lost |
| Transit key, deleted | 023 | HTTP 404 | unknown |
| KV path, metadata deleted | 024 | HTTP 404 | unknown |
| Transit version 1 under a floor of 2 | 025 | min_decryption_version: 2 |
blocked |
| Transit version 2 of the same key | 026 | within the floors | retained |
| Transit version 1, trimmed | 027 | min_available_version: 2 |
lost |
| soft delete undone | 034 | deletion_time cleared |
retained |
| floor lowered to 1 | 035 | within the floors | retained |
| destroyed version, after the reversals | 036 | destroyed: true |
lost |
| trimmed version, after the reversal was refused | 037 | min_available_version: 2 |
lost |
| three dependencies, partitioned | 040-042 | curl exit 7 | unknown |
after netjoin |
045 | version present | retained |
| paused | 047 | curl exit 28 after 5 s | unknown |
| sealed | 053 | HTTP 503 "Vault is sealed" | unknown |
| unsealed | 055 | version present | retained |
The reversals are the difference between blocked and lost in action. The undelete (031) and the
lowered floor (032) were accepted, and the executor decrypted version 1 again (038). Lowering
rc-trim's floor below the trimmed one was refused: "min decryption version should not be less
then min available version" (033). Before a trim, OpenBao requires min_encryption_version to be
raised as well as the decryption floor. The first attempt, without it, was refused (§5.1).
The deleted Transit key (023) and the path whose metadata was deleted (024) answer exactly as a name that never existed (010) does: HTTP 404 with an empty error list. The rule puts all three at unknown. Whether a Bronzeward reference to that name ever resolved is Bronzeward's own record, not the provider's.
4.2 Local stores¶
| Dependency and state | Row | Answer | Class | Value, read with the key |
|---|---|---|---|---|
| age generation, as written | 065-066 | digest matches | retained | as written (067) |
| age generation never written | 068 | no metadata file | unknown | |
| ciphertext removed, metadata kept | 070 | no such file | unknown | |
| ciphertext replaced by another value | 072 | digest differs | unknown | gone (073) |
| ciphertext re-encrypted, metadata not yet updated | 075 | digest differs | unknown | still there (076) |
| metadata file mode 000 | 078 | permission denied | unknown | |
| ciphertext mode 000 | 081 | permission denied | unknown | |
| modes restored | 083 | digest matches | retained | |
| key file deleted | 085 | digest matches | retained | unreadable (086) |
| SOPS file, as referenced | 089 | same MAC | retained | as written (090) |
| SOPS file, referenced value edited | 092 | another MAC | unknown | gone (093) |
| SOPS file, another key added | 095 | another MAC | unknown | still there (096) |
| SOPS file removed | 098 | no such file | unknown | |
| SOPS file mode 000 | 100 | permission denied | unknown | |
| mode restored | 102 | same MAC | retained | |
| key file deleted | 104 | same MAC | retained | unreadable (105) |
Neither local candidate has a blocked or an unreachable state, and rows 087-088 and 106-107 record that rather than leaving the cells blank. Nothing hides a file reversibly while keeping it. A local file has no remote end: an unreadable file is either denied or absent.
5. Failures hit while building it¶
5.1 A trim needs the encryption floor raised first¶
A first capture set rc-trim's decryption floor to 2 and then asked for a trim to 2. OpenBao refused
it with HTTP 400: "minimum available version cannot be set when minimum encryption version is not
set". The key stayed untrimmed, so the classifier correctly reported it blocked, and the three trim
rows were mismatches. The script now raises min_encryption_version along with the decryption
floor. That capture was discarded.
5.2 A changed digest or MAC was first classified lost¶
The first version of the classifier called a local ciphertext whose digest differed from the recorded one lost, and so a SOPS file whose MAC differed from the referenced one. The branch review showed both overclaim. The age store's digest is its own record, and the provider-capability run's rotation replaces the ciphertext before it updates the metadata. sops encrypts the MAC with a fresh IV on every save, so adding another key to the file changes it. The rows now show both: 075 and 095 are unknown while the value is still there (076, 096), exactly as 072 and 092 are where it is gone (073, 093). Metadata cannot tell the two apart, so both are unknown. The capture made before this change was discarded; the committed evidence is the capture after it.
5.3 Rules that accepted any version string¶
The same review found that a KV version of 0, -1 or 1.5 came out lost against an
oldest_version of 2, and that a Transit answer's soft_deleted field was ignored. Both are now
unknown, with checks for each in run/test-decide.
5.4 A denied ciphertext read as absent¶
The age-store classifier first took the ciphertext's digest through a redirection
(sha256sum <file). A redirection that fails reports on the shell's own stderr, before the
command's 2>&1 applies. A mode-000 ciphertext therefore produced no captured error and would have
been reported as absent instead of denied. Both are unknown, but the reason was wrong. It now passes
the file by name. This was caught before any capture.
5.5 Verbatim transcripts fail git diff --check¶
As in the provider-capability run, bao's trailing blank lines trip the whitespace check.
.gitattributes exempts this experiment's evidence/transcripts/*.txt alone.
5.6 A deletion time compared with the client's clock¶
The advisory review of the draft found that the classifier compared a KV deletion_time, which the
provider sets, with the client's own clock, read before the request. With the client behind the
provider, a version soft-deleted a moment earlier would have looked scheduled and been reported
retained, not blocked. The classifier now takes the comparison instant from the answer's Date
header, and a deletion time in the same second, or an answer without a readable Date, is
unknown. The rule checks cover each case. The earlier capture was not exposed to the defect, because
the provider's container shares the host's clock. It was nevertheless redone with the corrected
classifier, and the committed evidence is that capture. In it, the one soft deletion, made at
19:51:17 by the provider's clock, was classified blocked at a provider time of 19:51:29 (021).
6. What this decides¶
6.1 Criterion 1: metadata and permissions mapped to the four states¶
OpenBao supplies every state from the metadata token alone: read on secret/metadata/* and
transit/keys/*, plus list. Retained, blocked (soft delete, decryption floor) and lost
(destruction, pruning, trim) each rest on a field of the answer (§3.1, §4.1), and each was
observed against its ground truth. A version with a scheduled deletion was observed retained, with
the reason naming the time (002), so a monitor can warn before it arrives. That it turns blocked
once the time has passed is the soft-delete rule, covered by the rule checks and not captured.
The local age store supplies retained and unknown only. A missing, denied or changed file is unknown (§4.2). It has no blocked state, and no lost either: a digest that differs from its own record does not show the value gone (075-076).
SOPS likewise supplies retained and unknown only, against a MAC pinned when the file was referenced. The reference has to carry the MAC, since the file has no version number, and any rewrite changes it (095-096).
For both local candidates, retained means the metadata file and the ciphertext are there, and nothing about the key (085 and 104 retained with the key file gone; 086 and 105 the decrypt failing).
6.2 Criterion 2: nothing uncertain becomes lost¶
Each cause of uncertainty was produced and each landed on unknown:
| Cause | Rows |
|---|---|
| denied metadata | 008, 009 (OpenBao); 078, 081 (age store); 100 (SOPS) |
| a name the provider does not answer for | 010, 023, 024 (OpenBao); 068, 070 (age store); 098 (SOPS) |
| unreachable | 040-042 (partition, curl exit 7) |
| no answer | 047 (paused, curl exit 28) |
| sealed | 053 (HTTP 503) |
| insufficient evidence | 006 (a version above current_version); 072, 075, 092, 095 (a rewritten local file); the rule checks for malformed versions, a set soft_deleted, missing floors, missing version maps and unparseable answers |
Missing listings. The classifier never lists. It asks for each referenced dependency by name, so a listing without the entry is never evidence it uses. The same condition reaches it as the 404 on the direct read (023, 024), and is unknown. That a listing is never read was a design choice made to keep this criterion true; no LIST request was exercised.
No timeout moves unknown to lost. Row 043 is three observations of a partitioned dependency, 4 s
apart, with the alert interval set to 5 s. The monitor raised regression at once, because the
dependency had been retained, and persistent at the third observation. The class stayed unknown
throughout. The rule checks cover the same with an unknown observed 99,900 s after it began, and
with a dependency never seen retained.
The uncertain mutation. A bao-destroy sent while OpenBao was paused got no answer, and the
fixture logged it unknown rc=28 (048, injections.log). After unpause, the version was not
destroyed in this run (050, with the admin read 051 agreeing). The classifier reported what the
provider then held, and the fixture's unknown log line is the only record that a destroy may have
been sent. That is a single observation, not proof that OpenBao never applies such a request later.
6.3 Criterion 3: a retained verdict grants nothing¶
In the authority state (056-064), each dependency was classified retained and then used:
- The metadata token itself could not read the value it had just classified (057, HTTP 403).
- A compiler for another scope could not read it (058).
- The compiler could not decrypt the retained artifact (060, denied).
- The executor could not decrypt a damaged copy of it (061).
- After the executor's token was revoked, the key was still retained (063) and the executor was denied (064).
The same holds with a floor in the way: the executor's decrypt of version 1 failed while it was blocked (029), and succeeded once the floor was lowered (038). Retention is a property of the provider's state. Authority is the identity's policy at the moment of use. The experiment observed them change independently.
Dispatch is half of the criterion that this phase can only show by construction. There is no dispatcher in Phase 0 to consume a verdict. The classifier's only effect is a line of output, and the metadata token it holds can read no value (057) and use no key. A verdict can therefore authorize nothing by itself. Whether the v1 dispatcher treats it that way is for the execution specification, and §7 records it as a limit.
6.4 Criterion 4: provider limits and the alert policy the evidence supports¶
Provider limits.
- OpenBao: a deleted Transit key, a KV path whose metadata was deleted, and a name that never existed all answer HTTP 404 (010, 023, 024). There is no tombstone. The provider cannot say "lost" for either removal, so the classifier cannot either.
- OpenBao: the KV
max_versionsdefault prunes past ten versions (004,max_versions: 0in the answer, as §7.5 says). Pruning is visible only asoldest_version, never as a per-version flag. - OpenBao: Transit trim needs
min_encryption_versionraised as well (§5.1). A trim is visible only asmin_available_version, and the key'skeysmap hides trimmed and blocked versions alike. - OpenBao: a sealed or paused provider hides everything. The fixture's out-of-band read failed too, so for those states the ground truth is the state set, not a reading (§3.2).
- OpenBao: whether an unanswered mutation was applied can only be learned by asking afterwards.
- Local stores: only retained and unknown. No blocked state, and no removal the metadata can prove. A deleted key is invisible.
- Every candidate: presence is not usability (§6.1, §6.3).
Alert policy the evidence supports. It is a proposal for retention and recovery policy, not a setting:
- lost: alert at once. It rests on a positive field of the answer.
- blocked: alert at once, and say it is reversible. The admin's undelete or lowered floor restored use (034-035, 038).
- retained → unknown: alert at once as a regression. A 404 for a name that resolved before is what a deleted key or deleted metadata looks like (023-024), and a rewritten local file looks the same whether its value survived or not (072-075, 092-095). Only the transition gives these away.
- unknown that persists: alert after the interval. The evidence does not fix the interval; it shows only that a partition, a pause and a seal all look alike from the client, apart from the reason.
- retained with a scheduled deletion: warn before the time in the reason (002).
7. Limits¶
- One OpenBao topology, as in the provider-capability run: single node, one unseal share. Standby nodes, performance replicas and auto-unseal were not exercised. HTTP 503 was seen only as "sealed".
- The administrator ran as root. Rows 015-020 and 031-033 used the root token. A least-privilege administrator policy is still unwritten.
- One pass per state. In particular, the unanswered destroy was not applied in this one run.
- Dispatch is shown by construction only (§6.3). No dispatcher exists yet to test against.
- No LIST request was exercised (§6.2).
- The alert interval is a parameter (
RC_ALERT_AFTER, 5 s in row 043). It shows how the monitor behaves, not what the interval should be. - KV
delete_version_afterwas observed only before its time (§6.1). - Transit
soft_deletedis present in the 2.6.1 key answer (027) and was false throughout. Transit soft deletion was not exercised, so a set flag is classified unknown rather than blocked. - Host curl. The classifier's client is the host's curl, not a pinned image.
- The same-second window was not captured. The run's one soft deletion was classified 12 s after
it was made (021). The unknown for a deletion time in the answer's own second, and for an answer
without a
Dateheader, rests on the rule checks. Clock skew between a client and a provider on separate hosts was not produced. - Local stores ran as one uid, as before. Denial was produced with file modes, not separate users. SOPS was exercised in JSON format only.
- What the evidence carries. The command column of
verdicts.tsvholds the SHA-256 of each expected synthetic value, since the digest is an argument of the check, and row 062 holds the executor token's accessor, which identifies a token and cannot be used as one. The values and the token were per run and never committed.
8. Recommendation¶
This selects nothing. It tells deployment profile selection and retention and recovery policy what classification can rest on.
- OpenBao's metadata is sufficient for §7.6 under a metadata-only policy, with one exception: whole-key and whole-path deletion come out as unknown, not lost. §7.5 already makes restricting them a deployment requirement. The classifier needs Bronzeward's own record of what was referenced to turn that 404 into a regression alert.
- The local candidates give only retained and unknown. They have no blocked state, cannot prove a removal, and cannot see a key loss. §7.6 requires a local provider to "prove equivalent metadata behavior"; on this evidence neither does. This agrees with the provider-capability run: §7.1's condition for a local provider is not met.
- Retention and authority must stay separate checks, as §7.6 says. Nothing observed here would let a retained verdict stand in for a point-of-use read or decrypt.
9. Hand-off¶
To deployment profile selection: §8, and §6.4's provider limits.
To retention and recovery policy: the alert
policy of §6.4. The interval is still open. Two deployment requirements follow from §6.4: restrict
Transit key deletion and KV metadata deletion, whose results the classifier cannot tell from a name
that never existed; and set KV max_versions explicitly wherever a path is overwritten.
To key loss and restoration: a deleted key file leaves both local candidates retained (085, 104). Key loss has to be found by a use-time check or a key inventory, not by this classifier.