Skip to content

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 sops block, 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_versions default prunes past ten versions (004, max_versions: 0 in the answer, as §7.5 says). Pruning is visible only as oldest_version, never as a per-version flag.
  • OpenBao: Transit trim needs min_encryption_version raised as well (§5.1). A trim is visible only as min_available_version, and the key's keys map 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:

  1. lost: alert at once. It rests on a positive field of the answer.
  2. blocked: alert at once, and say it is reversible. The admin's undelete or lowered floor restored use (034-035, 038).
  3. 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.
  4. 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.
  5. 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_after was observed only before its time (§6.1).
  • Transit soft_deleted is 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 Date header, 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.tsv holds 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.

  1. 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.
  2. 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.
  3. 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.