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

@ -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": (