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-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-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-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-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-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 | | 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 # §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 # writer with may_read: false must therefore be able to ask, or the
# obligation audit-core argued for is undischargeable by every sender # 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": if method == "GET" and path == "/v1/reconciliation":
return self._reconciliation(start_response, environ, identity) return self._reconciliation(start_response, environ, identity)
@ -333,12 +335,26 @@ class IngestionApplication:
) )
def _reconciliation(self, start_response, environ, identity): 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 Counts, never payloads. The comparison a source makes against its own
source makes against its own state transitions is the §9.6 detection state transitions is the §9.6 detection obligation; audit-core
obligation; audit-core supplies the numerator and says nothing about supplies the numerator and says nothing about what the answer means.
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) query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True)
source = (query.get("source") or [""])[0] source = (query.get("source") or [""])[0]
@ -346,13 +362,19 @@ class IngestionApplication:
return self._json( return self._json(
start_response, HTTPStatus.BAD_REQUEST, {"error": "source_required"} 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 # 403 rather than an empty count. A zero here would read as "we
# hold none of yours", which is a materially different and false # hold none of yours", which is a materially different and false
# answer to a question about completeness. # answer to a question about completeness.
return self._json( return self._json(
start_response, HTTPStatus.FORBIDDEN, {"error": "source_not_allowed"} 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) window = _window(query)
if window is None: if window is None:
return self._json( return self._json(
@ -402,6 +424,7 @@ class IngestionApplication:
"until": until, "until": until,
"counts": counts, "counts": counts,
**({"stream_instance_id": stream_instance_id} if instances else {}), **({"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 # Said in the response because this is the number most likely to
# be quoted out of context in someone else's conformance argument. # be quoted out of context in someone else's conformance argument.
"means": ( "means": (

View file

@ -83,8 +83,21 @@ curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
# Every event sharing a correlation id # Every event sharing a correlation id
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \ curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
'http://127.0.0.1:8080/v1/events?correlation_id=<id>&limit=100' '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 Stored records keep the original `event_id`, `payload_hash`, and
`accepted_at`. Redacted fields appear in `details.redaction.paths`; the `accepted_at`. Redacted fields appear in `details.redaction.paths`; the
payload itself is stored already masked. 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 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` | | 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 | | — | `GET /v1/stream-findings`, and the `means` field on every finding |
`access-engine`'s expectation — heartbeat plus reconciliation rather than rate, `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 owner: codex
topic_slug: audit-evidence-dependencies topic_slug: audit-evidence-dependencies
created: "2026-10-04" created: "2026-10-04"
updated: "2026-10-15" updated: "2026-10-28"
state_hub_workstream_id: "9e400de2-6ba1-520e-9b25-94aa2470e528" 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. per-pin source cadence/runtime adoption and independent observation still wait.
The canonical sibling-owner fixture check passed1, resolving the isolated The canonical sibling-owner fixture check passed1, resolving the isolated
checkout's expected skip without modifying foreign source. 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.