Originally created by: fu351
Originally owned by: thesageak
A decision row records the verdict but not the policy that produced it. The policy catalogue (#513, docs/POLICY_VERSIONS.md) now gives every policy in force a content-hash identity (pv1: + SHA-256 of the canonical snapshot) in the append-only .doberman/policies.db, with a timeline of observations and a ledger_ts link to the policy_changes row that authorised each change. What is missing is the last hop: stamping that version on each decision, so an auditor can go from a log row to the exact policy content (SOC 2 CC6.3, NIST 800-53 CM-3, AU-3(1)).
Stamp it once, at the chokepoint. All three writers (proxy executor._persist, host-hook spine.record_history, demo) go through storage/log.py::record_decision → build_record. Inside the existing never-raises boundary, call policy_catalogue.observe_current(repo_root, origin="decision", enforcement_effective=<the state the writer already resolved>) and put the returned id in a new policy_version field. observe_current already dedupes (an observation is appended only when the version in force changed), so steady state costs one small hash plus config reads the hook path already does. A stamp failure leaves the column NULL and never touches the decision.
Storage. decisions.policy_version TEXT nullable, added the way _migrate_legacy adds columns (PRAGMA + ALTER TABLE); fresh DBs get it in _SCHEMA. Rows from before the column stay NULL and read as legacy — for those, policy_catalogue.version_at(ts) gives the best-effort answer by time.
Surfacing (the matching contract).
doberman log --jsonl emits policy_version (the id only — snapshot content stays behind policy-versions --show).doberman policy-history --json rows gain to_version: the version of the observation whose ledger_ts equals that row's ts, when one exists.doberman policy-versions --verify gains one more reported state, unledgered: a change-origin observation with a NULL ledger_ts, or a version first seen only via observed/decision — a policy the ledger never produced (for example a hand-edited policies.yaml). Report-only, exit 1, no clamping.Tests. All three writer paths stamp the same id for the same policy (the existing per-writer test files show the fixtures); a decision after a mode change carries the new version; the stamp failure path leaves NULL and the decision row intact; log --jsonl includes the field and never canonical; to_version joins correctly and is absent when no observation matches; a hand-edited policy surfaces as unledgered under --verify; a redaction check that policy_version can only carry a pv1: id.
Out of scope. Clamping or blocking on unledgered (that stays a report); signing (#146); any change to what the catalogue hashes (that is policy_catalogue.py's contract, pinned by its golden-vector test).
Originally posted by: thesageak
I'd like to work on this one!