T08 fixes two defects in our own declaration. provider.R.available quoted a 30-day horizon audit-core does not solely control: at P1 the erasure horizon is the instance maximum across co-residents, so a co-resident declaring longer extends what a sender's records remain recoverable for, silently. Decision 4.5.4 names this for tiers; it applies to a provider quoting a number too, and the provider block now says so. And user-engine, the only consumer, was notified under Decision 6.1 -- what we declared, that E4 and R4 are unreachable here, and that the retention number is a floor rather than a ceiling. T03 writes docs/erasure-and-audit.md: the fact/payload split, why shreddability is not retrofittable onto a chain committing to cleartext, and why the retained hash is a confirmation oracle over low-entropy audit records. The framework half was already resolved as Decision 4.5.3, so what remains is our own position. The legal basis for retaining audit facts is routed to risk-nexus, open and visible. T06 closes the review loop with net-kingdom: five findings adopted, declaration validates clean, and the E line will go stale on an upgrade that Decision 6.1 deliberately does not require anyone to announce. T07 enumerates the seven dependencies on the accept path and specifies five recovery scenarios with integrity as a pass condition. It settled one thing: V2 is not reachable from P1 as built, since platform-pg runs instances 1 and Decision 4.6.1 makes V the minimum across synchronous providers. V1 is the ceiling here, not the next step. The exercise needs a live window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | depends_on | state_hub_workstream_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| AUDIT-WP-0008 | workplan | Tenancy posture declaration and read-path enforcement | infotech | audit-core | ready | claude | railiance | 2026-08-17 | 2026-08-17 |
|
84c01552-4c65-45fd-bf6c-7daab0ecc9b6 |
AUDIT-WP-0008 — Tenancy posture declaration and read-path enforcement
Goal
Answer the rapp-postgres review request against NetKingdom Tenancy Posture
v0.1, declare an accurate posture vector, and close the one gap the assessment
exposed in running code.
Canonical framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md,
published by policy-nexus at policy.coulomb.social.
Framework moved twice under this workplan. The assessment below was made against draft-5; the framework is now at draft-8. The assessment still holds — no finding was reversed — but three things changed underneath it:
Rmoved 1 → 2 oncerapp-postgresdeclared the window, a sixth axis V (availability) appeared that we had no position on, and the implemented-versus-evidenced distinction we used informally forEbecame a schema field.tenancy.yamltracks draft-8 and validates clean againstnet-kingdom/tools/tenancy-posture/validate.py. T07 and T08 exist because of draft-8, not because of the original review.
Assessment — what the ladders say about us
rapp-postgres guessed audit-core I2 A3 E2 P1 R1, and §5 carries that guess
as a worked example. Three of the five are too high.
| Axis | Guess | Actual | Why |
|---|---|---|---|
| I | 2 | 1 | Senders hold static bearer tokens (audit_core/senders.py); the tenant arrives in the request body (audit_core/ingestion.py:379) and is checked against an allowlist bound to the credential. No IdP, no verified claim. §4.1 names this case explicitly: I1 absorbs "the tenant is taken from the request rather than from a verified token". |
| A | 3 | 2 | No flex-auth call exists anywhere in the repo. Authorization is a single local boundary — permits_source, permits_tenant, may_write, may_read. That is the definition of A2. |
| E | 2 | 1 | The write path is E2-shaped. The read path applies no tenant filter at all. See below. |
| P | 1 | 1 | platform-pg, database per consumer. Correct. |
| R | 1 | 1 | No backupRetentionDays in rapp-postgres/consumers/audit-core.yaml; platform default 30 days applies. Correct. |
The I-axis correction is not a defect. The credential→tenant allowlist is a real control; it is simply an A-axis control sitting where a reader would expect an I-axis one. Decision 3.1 doing its job on its first live use.
The E-axis finding
_read (audit_core/ingestion.py:226-272) gates on identity.may_read and
nothing else. permits_tenant is never called on the read path. A credential
with may_read: true can read any tenant's events through
/v1/events?correlation_id=, /v1/events/<id>, /v1/dead-letters and
/v1/secret-findings.
Exposure today is bounded: the production sender user-engine carries
may_read: false, and the read surface is ClusterIP behind a default-deny
NetworkPolicy. But bounded-by-deployment is not enforced-by-code, and under §6
declaring E2 on that basis would be the overclaim the framework exists to
prohibit. E1 is the honest reading until T04 lands.
This also corrects the premise of the review request. rapp-postgres wrote
"you are at E2, and E3 is more urgent for you than for anyone else". E3 is
indeed the right target for a service holding audit evidence, but §4.3 requires
the E2 evidence artifact alongside any E3 claim — so the first move is reaching
E2, not adopting RLS.
Positions to take
On the retention ceiling (their question 1)
The 365-day ceiling is not wrong for us. Keep it.
The ADR-0002 context line — "audit-core holds audit evidence and may need
years" — is right about the requirement and wrong about the mechanism. Physical
backup retention is not where multi-year audit custody belongs. It is already
routed: data/capability/audit-core-operational.json records data.archive
as an unmet requirement with a separate owner and an explicitly different
bucket and lifecycle from Barman.
Consequences we accept and want stated back:
- audit-core declares nothing above the 30-day platform default. We therefore do not extend any co-resident's erasure horizon, now or under this plan.
- Multi-year retention is an archive procurement question, not a WAL-window question. Raising the ceiling would buy us nothing and cost every co-resident volume against 20Gi.
- Service class
batchis correct. No correction wanted. Co-residency with latency-criticaltenant-engineis right to be reported under §8.3.3.
Ask of rapp-postgres: amend the ADR-0002 opening context to point at archive
rather than at backup retention, so the two statements agree.
This position is conditional, and the condition is dated. Routing multi-year
custody to data.archive is correct mechanically and unfunded practically:
the capability case records it as unmet, with no booked cost and no procurement
owner in motion. Taking this position without a date would read as "audit-core
is fine", and audit-core is not fine — it is correctly routed and unresourced.
Escalation: reviewed 2026-12-31. If no data.archive provision is procured
by then, audit-core reopens placement. §4.5 makes retention a placement trigger,
and P2 — a dedicated cluster with an independent window — becomes the fallback
worth arguing for. P2 still does not deliver WORM, so it is a worse answer than
archive; it is named here so the fallback exists rather than being improvised
under pressure. Recorded now so the review has something to fire against.
Also taken now: R1 → R2. R2 needs only that retention be declared per
dataset rather than inherited silently, and that the erasure horizon be
published. That is an explicit backupRetentionDays: 30 in the consumer
declaration plus the horizon rendered in our operator surface — a change of
hours, not a future target. Deferring it as a "target" would have been the
framework's own §8.1 failure: a value in a file nobody re-reads.
On erasure versus audit (their question 11)
Yes to the fact/payload split — retain the fact of an event, encrypt its personal payload per subject, shred the key. But that resolution does not survive contact with our integrity design as built, in two ways the framework does not currently capture.
One: it is not retrofittable. _record_hash
(audit_core/postgres_backend.py:583) is SHA-256 over the canonical JSON of the
cleartext record, and that hash is what the chain commits to. Encrypting a
payload after the fact severs the binding between payload_hash and the stored
record. Shreddability has to be built as encrypt-then-hash at accept time, with
the chain committing to ciphertext. Retrofitting it onto existing events is not
possible without rewriting the chain, which is the one thing AUDIT-WP-0007 was
built to make detectable.
Two, and sharper: the retained hash is a confirmation oracle. A SHA-256 over a canonical record whose fields are low-entropy (event type, actor, tenant, subject, timestamp) lets anyone holding the hash guess the payload, hash the guess, and confirm a match. Destroying the key does not make the content unrecoverable while that hash survives. Crypto-shredding does not reach R4 under this design. It needs the integrity commitment to be an HMAC under a per-subject key that dies with the key, or a per-record salt destroyed with it.
This is a genuine gap in §4.5, not just a fact about us: the key-destroyed route treats key destruction as sufficient without requiring that no retained commitment reveal the erased content. Recommend §4.5 gain that condition.
Our position: audit-core targets R2, not R4. Verified erasure of audit evidence is a design tension we record rather than resolve, and a "R4 everywhere" fleet target must exempt this service explicitly.
The exemption rests on a legal basis nobody in this estate has confirmed, and that must be said out loud rather than assumed. Declining to build erasure means that if an Article 17 request arrives naming a data subject in the audit trail, audit-core has no mechanism and must rely on audit evidence being exempt — legal obligation, or legitimate interest in fraud and security investigation. Those grounds are ordinary and widely relied on. They are also a legal conclusion, and this repo is not competent to reach one. Nothing in INTENT, SCOPE or the capability case records anyone having reached it either.
This workplan therefore routes the question rather than absorbing it: the position is "we do not build erasure, on a legal basis we have named and not validated", and the validation is somebody else's to own. An unnamed dependency here is the difference between a recorded tension and an unnoticed liability — and an audit service discovering in the middle of a regulator's question that its exemption was assumed is the specific failure worth spending a paragraph to avoid.
Framework review findings
Sent under §20.2 — the framework is validated by whether it can describe us.
Outcome, 2026-08-17: all five were adopted in draft-7, alongside reviews from
railiance-platformandflex-auth. Finding 1 became Decision 5.2 (declare per path, quote the minimum), finding 2 became 13.1a (the floor needs a reason, not an artifact), finding 3 became Decision 9.2 (consumer-facing credentials are named in), finding 4 became Decision 4.5.3 (key destruction is not sufficient alone; the confirmation-oracle defect is recorded as general, with audit-core exempted from R4), and the §5 worked example now readsI1 A2 E1 P1 R1self-reported. Draft-7 also added Decision 5.4, which relocates the vector totenancy.yamlin the repo root — see T01.Two of these now bind us rather than only the framework: 9.2 makes our static ingest tokens a declarable gap, and 5.5 requires a provider block. Both are in
tenancy.yaml. T06 is therefore reduced to confirming receipt and correcting the one line draft-7 still has stale about us (E, once T05 lands).Draft-7's own status section still carries "Every correction so far was found by research or by relocation, not by review" and "Reviewed by nobody yet", three paragraphs below a list of eleven review-driven changes. Stale lines, worth flagging in T06 — a document about not overclaiming should not under-report its own review history.
-
The E ladder cannot express asymmetric enforcement. Our write path is E2 and our read path is E1. §4.3 assumes one level per service. This will recur estate-wide, since most services enforce harder on write than on read, and with no rule everyone will declare their best path.
Neither obvious fix is right alone. A bare minimum across paths is safe to publish but destroys information: a service at E3-write/E1-read declares identically to one at E1/E1, though the first is materially further along. Bare per-path declaration (
E: {write: 3, read: 1}) keeps the signal and invites exactly the selective quoting §6 exists to stop — "our write path is E3" is a sentence someone will say.Recommend both, with the relationship stated: declare per path, quote the minimum. The posture vector's
Eis the minimum across paths; the per-path breakdown sits beside it and is where improvement is tracked. One sentence in §4.3, and it is the only version that keeps honesty and signal at once. -
§13.1 collides with the ladder floor. "A level is claimed only with its evidence artifact present", but §13's table has no artifact for I0, I1, A0, A1, E0, E1, R0 or R1. §5's own worked example of a conformant absorbed repo —
I1 A1 E1 P0 R0— cannot satisfy 13.1 on any axis. Either declaration alone is the evidence at the floor rungs, or 13.1 binds only above them. Small, but it makes the framework's flagship example non-conformant against its own rule. -
§9.1 omits consumer-facing credentials. Decision 9.1 bans static long-lived credentials for database access above E0, and we comply — leases re-read at connection time,
audit_core/credentials.py. But our ingest credentials are static long-lived bearer tokens rotated by publishing a second token alongside the first. §9 promotes leased credentials to "a tenancy control"; the same argument applies with more force to the credential that actually carries the tenant claim. The omission reads as accidental. Name consumer-facing credentials in scope, or say deliberately that they are not. -
§4.5 key-destroyed route — add the condition in "On erasure versus audit" above: no retained integrity commitment may reveal the erased content.
-
§5 worked example — correct
audit-coretoI1 A2 E1 P1 R1, and revise its note. "Both E3 and R2 are urgent targets" is half right: E3 is, R2 is nearly vacuous for us while the real requirement sits indata.archive.
Nothing here asks the framework to change shape. Four of five axes described us without strain, and the one that did not (E) failed in a way that found a real defect in our code. That is the framework working.
Non-goals
-
E3 / row-level security is not in this plan. ADR-0003 publishes the contract and it is the right target, but it is blocked behind reaching E2 (§4.3) and needs the
EXPLAINcomparison first. Declared as target with a recorded gap in T01, not built here.The target carries a date: 2027-03-31. The framework says services whose cross-tenant exposure would be a reportable breach SHOULD target E3, and no service on
platform-pgfits that description better than this one. Every quarter spent at E2-with-a-target is a quarter of stated, unclosed risk, and an undated target is what §8.1 calls documentation rather than control. If the date slips it slips as a decision, which is the whole point of §12. -
No per-subject encryption is built. T03 records a position; it does not commit to an implementation.
-
No adversarial evidence facility is built here. §19.3 gave that a home in
whitehat-security. T05 coordinates, it does not volunteer.
Sequencing
T04 runs before T01. Task ids are hub-registered and not renumbered; the execution order is T02 → T04 → T01 → T03 → T05 → T06.
The reason is narrow. A posture vector published in the repo is a canon-shaped
artifact that outlives the fortnight it takes to fix the read path, and E1 on
an audit service is a sentence that will be quoted back long after it stopped
being true. Fixing first means the declaration is E2 on both paths from its
first commit, and the E1 interval never enters the permanent record.
This is emphatically not withholding the finding. T02 goes first precisely
so the correction reaches rapp-postgres immediately: their guess of E2 is
live in canon now, and it is wrong now. A message correcting the record is the
fast instrument; a published vector is the durable one. Deferring the durable
artifact by two weeks while the fast one goes out today is sequencing. Delaying
both would be concealment, and §6 would be right to call it that.
Tasks
id: AUDIT-WP-0008-T01
status: done
priority: high
state_hub_task_id: "65f3109a-fa98-459a-9e59-d7ac211bd2bc"
Publish the posture vector. Rewritten against draft-7, which landed after
this task was written and moved the target: Decision 5.4 fixes the location at
tenancy.yaml in the repo root, not docs/tenancy-posture.yaml, and fixes
the schema — current, target, reviewed, gap, placement_exceptions,
service_class, per-path detail (5.2), and a provider block (5.5). Delivered at
that path and in that shape. Cross-linked from SCOPE.md and
docs/interface-card.yaml.
Declared I1 A2 E1 P1 R1, and E is quoted at 1 on purpose. T04 put the E2
mechanism on both paths, but §13.2 says a passing CI run is not E2 evidence:
the E2 artifact is adversarial, compares separate tenant contexts, and carries a
review date rather than a green build. Our cross-tenant tests are mechanical.
Under §13.1 the level is therefore not claimable until T05 produces the artifact,
so the vector declares 1 with the mechanism recorded in paths.E and the reason
in gap.E. Claiming E2 off unit tests would be the overclaim §6 prohibits —
and refusing exactly that kind of reasoning is what found the read-path defect
in the first place.
R stays at 1: R2 needs backupRetentionDays: 30 in
rapp-postgres/consumers/audit-core.yaml, requested in T02. That file is
theirs, so R2 is not ours to declare unilaterally. The erasure horizon is
already published on /readyz as recoverable_days.
Two additions draft-7 forced that the original task did not anticipate:
- A
credentialsgap under Decision 9.2. Our own finding was adopted as a rule, and it binds us: ingest credentials are static long-lived bearer tokens. Declared as a stated gap with no dated remedy rather than a silent exclusion. Asking for the rule and then exempting ourselves from it was not available. - A
providesblock under Decision 5.5. audit-core provisionsoperations.audit, so it declares what it makes reachable for a sender's trail — E2 now, E3 pending ADR-0003, E4 and R4 unreachable. A sender needing a structural cross-tenant guarantee cannot get it here and should be told so rather than sold E2 in E4's language (§11.4).
id: AUDIT-WP-0008-T02
status: done
priority: high
state_hub_task_id: "224a12c0-d646-4c7f-9215-120e1ede4691"
Reply to rapp-postgres message 56b77e20-0152-4e09-8f76-99640269063b.
Runs first. Carries four things: the retention position (ceiling stays 365,
audit-core declares nothing above the 30-day default, multi-year routes to
data.archive) with the 2026-12-31 escalation date and the P2 fallback named;
the request for an explicit backupRetentionDays: 30 in consumers/audit-core.yaml
to take us to R2; the ADR-0002 context amendment; and batch confirmed.
It also corrects their posture guess immediately — I1 A2 E1 P1 R1, not
I2 A3 E2 P1 R1, with the read-path defect stated plainly and T04 named as the
fix in flight. Their E2 guess is live in canon and wrong today; the correction
does not wait on T01. Mark the message read on send.
id: AUDIT-WP-0008-T03
status: done
priority: high
state_hub_task_id: "0003954e-5033-457f-b9aa-97efdefe9beb"
Answer question 11 in docs/erasure-and-audit.md.
Reduced by draft-8. The framework half is already resolved upstream: the confirmation-oracle finding landed as Decision 4.5.3, and §19.11 is now marked framework resolved, recording that audit-core targets R2 and is explicitly not a fleet R4 target. There is no amendment left to send. What remains is audit-core's own position document — the fact/payload split, the encrypt-then-hash constraint, why the retained cleartext hash defeats key destruction, and the R2 ceiling — so the reasoning lives in this repo rather than only in someone else's canon.
Route the legal question to risk-nexus, not the-custodian. §19.11 ends
"the legal basis for retaining audit facts remains a risk/legal question outside
this framework", and policy-nexus INTENT records that judging what an external
rule demands of the estate moved to risk-nexus on 2026-08-17. That is the
owner. The position is publishable with the dependency open; it is not
publishable with the dependency hidden.
id: AUDIT-WP-0008-T04 status: done priority: high state_hub_task_id: "e4440db3-6eb0-49f6-9aa9-930db8be9d18"
Bind the read path to permitted tenants. `permits_tenant` governs read as it
governs write, at the same choke point: tenant-scope `/v1/events`,
`/v1/events/<id>`, `/v1/dead-letters` and `/v1/secret-findings`. Decide and
record how an operator identity holding `tenants: ["*"]` is distinguished from a
scoped reader. Tests for cross-tenant read refusal. Update `docs/interface-card.yaml`
and the operator runbook. Raises E to 2 on both paths; update T01's declaration.
```task
id: AUDIT-WP-0008-T05
status: wait
priority: medium
state_hub_task_id: "30b56b30-e7eb-4873-aa7c-f4a3bf2fcb24"
Produce the §13 E2 evidence artifact — adversarial, with a review date: an
identity bound to tenant A demonstrably cannot read tenant B. Coordinate with
whitehat-security (WHITEHAT-WP-0001) on whether this is theirs to run or ours
to write and theirs to review. Record the review cadence as the exposure window
per §19.3.
id: AUDIT-WP-0008-T06
status: done
priority: medium
state_hub_task_id: "ed58ca37-755b-4284-b1ac-2a02baaceb21"
Close the review loop with net-kingdom.
Mostly overtaken. All five findings were adopted across draft-7 and draft-8, and the two stale status lines this task was going to flag are already fixed. Nothing is outstanding as a finding.
What remains is confirmation rather than argument: acknowledge the adoptions,
confirm the §5 worked example I1 A2 E1 P1 R2 V0 is accurate as written, and
state that tenancy.yaml validates clean against
tools/tenancy-posture/validate.py. Flag one thing forward — the E line becomes
wrong the day T05 lands, and under Decision 6.1 an upgrade has no notification
duty, so the correction is ours to send unprompted or it will simply sit stale.
id: AUDIT-WP-0008-T07
status: progress
priority: high
state_hub_task_id: "e972a8f7-e6ee-4522-bc76-fb69d71e5ed5"
Reach V1 on the availability axis, new in draft-8.
We declared V0 because nothing exercises restart recovery for the complete
audit path. §13's V1 row asks for three things and they are all mechanical:
critical dependencies enumerated, restart/recreate recovery exercised, and the
interruption plus measured recovery time recorded. Decision 4.6.1 makes V
end-to-end, so the enumeration must include platform-pg and the credential
path — a receiver that restarts in 5s over a database that takes 90s is not V1
at 5s.
The 2026-08-16 reboot walk in the operator runbook is close but is not this: it observed ~40s of unreadiness after a node reboot, without enumerating the dependency set or recording a measured recovery time as an exercise. Decision 4.6.1 is explicit that a replica count or a status page is not evidence.
Delivered in docs/availability-recovery.md: the seven-dependency enumeration,
five exercise scenarios with integrity as a pass condition, and the measurement
rules. The exercise itself needs a live window on railiance01 and coordination
with user-engine, since two scenarios are visible to the sender.
One thing the enumeration settled: V2 is not reachable from P1 as built.
platform-pg runs instances: 1, and Decision 4.6.1 makes V the minimum across
synchronous providers — so no amount of replication on our Deployment raises it.
V1 is the ceiling here, not merely the next step.
id: AUDIT-WP-0008-T08
status: done
priority: high
state_hub_task_id: "50eed5e2-06d6-4bdc-913d-5a8cd41ce613"
Disclose the shared-substrate retention coupling in our provider block, and
notify user-engine under Decision 6.1.
Two defects in our own declaration, both found by reading draft-8 back against
tenancy.yaml:
provider.R.available: 2is misleading as written. It tells a sender it gets a 30-day horizon. At P1 the horizon is the instance maximum across co-residents (§4.5), so a co-resident declaring longer silently extends what we hand our senders. Decision 4.5.4 makes exactly this the reason a bareR2minimum is insufficient. We are not a tier, but we are a provider quoting a number we do not solely control, and the provider block must say so.- Our consumers were never told. Decision 6.1: updating
tenancy.yamlwithout notifying dependants is declaration drift, not a completed change.user-engineis the only sender and has not been told thatE4andR4are unreachable here, nor that currentEis 1. This is not a downgrade — it is a first accurate declaration replacing someone else's generous guess — but the notification duty is the same and the guess was public.
Done when
docs/tenancy-posture.yamldeclares an accurate vector with targets, per-path E breakdown, and dated gaps.- Read path enforces tenant scope, with a test that fails if it regresses.
rapp-postgreshas the retention answer, the corrected posture guess, and the question 11 answer.- NetKingdom has the five framework findings.
- The §5 worked example for
audit-coreis corrected upstream. - Two dates are recorded and monitored per §8.1 rather than merely written:
data.archivereview 2026-12-31, E3 target 2027-03-31. - The erasure legal question has an owner outside audit-core (
risk-nexus). tenancy.yamlvalidates clean against the normative validator, and keeps doing so as the framework revises.- V1 is evidenced by an exercise, not asserted from a restart policy (T07).
- The provider block discloses what it does not solely control, and every
consumer of
operations.audithas been told what it declares (T08).