AUDIT-WP-0013-T06: independent full-scope readers may count any source (NK-WP-0035-T04)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 4s

GET /v1/reconciliation now admits may_read + tenants ["*"] identities for
any source, marked basis: independent_reader. Scoped readers get 403
full_tenant_scope_required; writers keep 403 source_not_allowed. Counts only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
tegwick 2026-10-28 09:49:10 +01:00
parent 64c0fa5285
commit d46e89ee21
6 changed files with 238 additions and 9 deletions

View file

@ -97,6 +97,8 @@
| task | AUDIT-WP-0013-T03 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md |
| task | AUDIT-WP-0013-T04 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md |
| task | AUDIT-WP-0013-T05 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md |
| task | AUDIT-WP-0013-T06 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md |
| task | AUDIT-WP-0013-T07 | wait | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md |
| task | AUDIT-WP-0014-T01 | done | — | workplans/AUDIT-WP-0014-execution-attribution-receipt.md |
| task | AUDIT-WP-0014-T02 | wait | — | workplans/AUDIT-WP-0014-execution-attribution-receipt.md |
| task | AUDIT-WP-0015-T01 | wait | — | workplans/AUDIT-WP-0015-independent-evidence-returns.md |

View file

@ -185,7 +185,9 @@ class IngestionApplication:
# §9.6 makes that comparison the source's own detection obligation. A
# writer with may_read: false must therefore be able to ask, or the
# obligation audit-core argued for is undischargeable by every sender
# actually registered. Cross-source counts stay behind may_read.
# actually registered. Cross-source counts stay behind may_read AND
# full tenant scope (AUDIT-WP-0013-T06): an operator who may already
# read every event by id may also count another source's window.
if method == "GET" and path == "/v1/reconciliation":
return self._reconciliation(start_response, environ, identity)
@ -333,12 +335,26 @@ class IngestionApplication:
)
def _reconciliation(self, start_response, environ, identity):
"""Per-class counts of one source's own events (AUDIT-WP-0009-T06).
"""Per-class counts of one source's events (AUDIT-WP-0009-T06).
Counts, never payloads, and never another source's. The comparison a
source makes against its own state transitions is the §9.6 detection
obligation; audit-core supplies the numerator and says nothing about
what the answer means.
Counts, never payloads. The comparison a source makes against its own
state transitions is the §9.6 detection obligation; audit-core
supplies the numerator and says nothing about what the answer means.
Two callers are admitted, and only two (AUDIT-WP-0013-T06):
* the source itself — any identity whose ``sources`` permit it, with or
without ``may_read``;
* an independent reader — ``may_read`` with full tenant scope
(``tenants: ["*"]``). Such an identity can already read every stored
event by id or correlation, so per-class counts tell it strictly
less than it may read. A tenant-scoped reader is refused: counting
another source's whole window enumerates existence beyond what an
id lookup filtered to its tenants discloses. The response carries
``basis: "independent_reader"`` so a receipt cannot be mistaken for
the source's own reconciliation.
A writer without ``may_read`` still may not count another source.
"""
query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True)
source = (query.get("source") or [""])[0]
@ -346,13 +362,19 @@ class IngestionApplication:
return self._json(
start_response, HTTPStatus.BAD_REQUEST, {"error": "source_required"}
)
if not identity.permits_source(source):
independent = not identity.permits_source(source)
if independent and not identity.may_read:
# 403 rather than an empty count. A zero here would read as "we
# hold none of yours", which is a materially different and false
# answer to a question about completeness.
return self._json(
start_response, HTTPStatus.FORBIDDEN, {"error": "source_not_allowed"}
)
if independent and not identity.has_full_tenant_scope():
return self._json(
start_response, HTTPStatus.FORBIDDEN,
{"error": "full_tenant_scope_required"},
)
window = _window(query)
if window is None:
return self._json(
@ -402,6 +424,7 @@ class IngestionApplication:
"until": until,
"counts": counts,
**({"stream_instance_id": stream_instance_id} if instances else {}),
**({"basis": "independent_reader"} if independent else {}),
# Said in the response because this is the number most likely to
# be quoted out of context in someone else's conformance argument.
"means": (

View file

@ -83,8 +83,21 @@ curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
# Every event sharing a correlation id
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
'http://127.0.0.1:8080/v1/events?correlation_id=<id>&limit=100'
# Independent per-class counts for ANY source's window (AUDIT-WP-0013-T06).
# Needs may_read AND tenants ["*"]; a scoped reader gets 403
# full_tenant_scope_required, a writer 403 source_not_allowed.
# Window is half-open on accepted_at: since <= accepted_at < until.
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
'http://127.0.0.1:8080/v1/reconciliation?source=<source>&tenant=<tenant>&since=<rfc3339>&until=<rfc3339>&stream_instance_id=<id>'
```
The independent count response is the own-source shape plus
`basis: "independent_reader"`: `source`, `tenant`, `since`, `until`,
`counts` (`[{class, count}]`), optional `stream_instance_id`, and `means`.
Counts only. Sequence and lag are not aggregated; take them from the
exact-id reads above (`accepted_at`, `observed_at`, `details.data`).
Stored records keep the original `event_id`, `payload_hash`, and
`accepted_at`. Redacted fields appear in `details.redaction.paths`; the
payload itself is stored already masked.

View file

@ -87,7 +87,7 @@ needs:
| --- | --- |
| The published class — load-bearing, and rare or volume | Accepting that classification as supplied and never inferring it |
| The cadence, per class | `heartbeat_classes` on the registration; `POST /v1/events` class `audit-core.heartbeat` |
| Reconciliation of its own counts | `GET /v1/reconciliation`, scoped to the caller's own sources, counts and never payloads |
| Reconciliation of its own counts | `GET /v1/reconciliation`, scoped to the caller's own sources, counts and never payloads; an independent full-scope reader (`may_read`, `tenants: ["*"]`) may count any source, marked `basis: independent_reader` (AUDIT-WP-0013-T06) |
| — | `GET /v1/stream-findings`, and the `means` field on every finding |
`access-engine`'s expectation — heartbeat plus reconciliation rather than rate,

View file

@ -0,0 +1,144 @@
"""AUDIT-WP-0013-T06 — independent cross-source reconciliation counts.
NK-WP-0035-T04: an operator reader registered for one source must be able to
count another source's window, without payloads, and only with full tenant
scope. A writer still may not count another source.
"""
import io
import json
import pytest
from audit_core.ingestion import IngestionApplication
from audit_core.senders import SenderIdentity, SenderRegistry
from audit_core.sqlite_backend import SQLiteAuditBackend
def invoke(app, path, *, token, method="GET", payload=None, key=None):
raw = json.dumps(payload).encode() if payload is not None else b""
environ = {
"PATH_INFO": path.split("?")[0],
"QUERY_STRING": path.split("?")[1] if "?" in path else "",
"REQUEST_METHOD": method,
"CONTENT_LENGTH": str(len(raw)),
"wsgi.input": io.BytesIO(raw),
"HTTP_AUTHORIZATION": f"Bearer {token}",
}
if key:
environ["HTTP_IDEMPOTENCY_KEY"] = key
result = {}
out = b"".join(app(environ, lambda status, headers: result.update(status=status)))
return result["status"], (json.loads(out) if out else {})
TENANT = "tenant:friendly:binky"
INSTANCE = "b351fa6b-e0d6-4f88-b7c9-6bacb1a112b7"
WINDOW = "&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00"
QONTO = SenderIdentity(
name="qonto-assistant", tokens=("qonto",), sources=frozenset({"qonto-assistant"}),
tenants=frozenset({TENANT}), may_write=True, may_read=False,
)
USER_ENGINE = SenderIdentity(
name="user-engine", tokens=("writer",), sources=frozenset({"user-engine"}),
tenants=frozenset({"*"}), may_write=True, may_read=False,
)
OPERATOR = SenderIdentity(
name="operator", tokens=("operator",), sources=frozenset({"user-engine"}),
tenants=frozenset({"*"}), may_write=False, may_read=True,
)
SCOPED_READER = SenderIdentity(
name="scoped-reader", tokens=("scoped",), sources=frozenset({"user-engine"}),
tenants=frozenset({TENANT}), may_write=False, may_read=True,
)
@pytest.fixture
def app(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "s.db"))
return IngestionApplication(
backend, SenderRegistry([QONTO, USER_ENGINE, OPERATOR, SCOPED_READER])
)
def emit(app, event_id, action, *, instance=INSTANCE, secret=None):
data = {"stream_instance_id": instance, "sequence": 1}
if secret:
data["binding"] = secret
payload = {
"id": event_id, "type": action, "source": "qonto-assistant",
"subject": "s:1", "tenant": TENANT, "correlation_id": "c-1",
"occurred_at": "2026-10-15T19:40:00+00:00", "data": data,
}
status, _ = invoke(app, "/v1/events", token="qonto", method="POST",
payload=payload, key=event_id)
assert status.startswith("202")
def count(app, token, query):
return invoke(app, "/v1/reconciliation?" + query + WINDOW, token=token)
def test_full_scope_reader_counts_another_source(app):
emit(app, "h-1", "audit.heartbeat")
emit(app, "h-2", "audit.heartbeat")
emit(app, "d-1", "audit.deny")
emit(app, "x-1", "audit.deny", instance="other-instance")
status, body = count(
app, "operator",
f"source=qonto-assistant&tenant={TENANT}&stream_instance_id={INSTANCE}",
)
assert status.startswith("200")
assert body["counts"] == [
{"class": "audit.deny", "count": 1},
{"class": "audit.heartbeat", "count": 2},
]
assert body["basis"] == "independent_reader"
assert body["stream_instance_id"] == INSTANCE
def test_full_scope_reader_may_omit_tenant(app):
emit(app, "d-1", "audit.deny")
status, body = count(app, "operator", "source=qonto-assistant")
assert status.startswith("200")
assert body["tenant"] is None
assert body["counts"] == [{"class": "audit.deny", "count": 1}]
def test_independent_counts_never_carry_payloads(app):
emit(app, "d-1", "audit.deny", secret="value-not-to-leak")
_, body = count(app, "operator", f"source=qonto-assistant&tenant={TENANT}")
assert set(body) == {
"source", "tenant", "since", "until", "counts", "means", "basis",
}
assert "value-not-to-leak" not in json.dumps(body)
def test_tenant_scoped_reader_may_not_count_another_source(app):
emit(app, "d-1", "audit.deny")
status, body = count(app, "scoped", f"source=qonto-assistant&tenant={TENANT}")
assert status.startswith("403")
assert body["error"] == "full_tenant_scope_required"
def test_writer_still_may_not_count_another_source(app):
status, body = count(app, "writer", f"source=qonto-assistant&tenant={TENANT}")
assert status.startswith("403")
assert body["error"] == "source_not_allowed"
def test_own_source_reconciliation_is_unchanged(app):
emit(app, "d-1", "audit.deny")
status, body = count(app, "qonto", f"source=qonto-assistant&tenant={TENANT}")
assert status.startswith("200")
assert "basis" not in body
assert body["counts"] == [{"class": "audit.deny", "count": 1}]
def test_independent_reader_still_needs_a_bounded_window(app):
status, body = invoke(
app, "/v1/reconciliation?source=qonto-assistant", token="operator"
)
assert status.startswith("400")
assert body["error"] == "since_and_until_required"

View file

@ -8,7 +8,7 @@ status: blocked
owner: codex
topic_slug: audit-evidence-dependencies
created: "2026-10-04"
updated: "2026-10-15"
updated: "2026-10-28"
state_hub_workstream_id: "9e400de2-6ba1-520e-9b25-94aa2470e528"
---
@ -154,3 +154,50 @@ capped and cannot serve as complete export. Operational repeated ledger custody,
per-pin source cadence/runtime adoption and independent observation still wait.
The canonical sibling-owner fixture check passed1, resolving the isolated
checkout's expected skip without modifying foreign source.
## Sanction independent cross-source reconciliation counts
```task
id: AUDIT-WP-0013-T06
status: done
priority: high
```
NK-WP-0035-T04 (State Hub message 6ed5cafc, receipt
net-kingdom docs/evidence/2026-10-15-cadence-adoption/receiver-readback-20261023.json):
the operator reader is registered for source `user-engine`, so
`GET /v1/reconciliation?source=qonto-assistant` returned 403 `source_not_allowed`
while exact-id reads succeeded 3/3.
Decided: option (a). `GET /v1/reconciliation` admits a second caller class, an
independent reader holding `may_read: true` and `tenants: ["*"]`, for any
source. Such a reader may already read every event by id or correlation, so
per-class counts disclose strictly less. A tenant-scoped reader gets 403
`full_tenant_scope_required` (counting a whole window enumerates existence
beyond a tenant-filtered id lookup); a writer without `may_read` still gets 403
`source_not_allowed`. Own-source behaviour and response shape are unchanged.
Independent answers carry `basis: "independent_reader"` so a receipt cannot be
mistaken for the source's own §9.6 reconciliation. No new role, endpoint or
registry field: the existing operator identity is the sanctioned reader.
Response: `source`, `tenant` (null if omitted), `since`, `until` (normalized,
half-open on `accepted_at`), `counts` `[{class, count}]`, optional
`stream_instance_id`, `basis`, `means`. Counts only; sequence and lag are not
aggregated and stay with the exact-id reads (`accepted_at`, `observed_at`,
`details.data`). Source: audit_core/ingestion.py `_reconciliation`; tests:
tests/test_independent_reconciliation.py (7). Runbook and section-4 docs updated.
```task
id: AUDIT-WP-0013-T07
status: wait
priority: high
blocking_reason: "Founder-run: build/publish an image from the T06 commit, pin its digest in deploy/audit-core.yaml, roll out, then NetKingdom reruns its readback."
blocked_on: founder
```
No registry or Secret change is expected: the operator identity already reads
by id across tenants. Value-safe precheck with the operator token: `GET /v1/stats`
returns 200 only for a full-scope reader (403 `full_tenant_scope_required`
otherwise, which would need a registry scope change instead). The deployed
b479801b receiver predates T06 and will keep returning `source_not_allowed`
until the new image runs.