# Durable gateway delivery reporting

17 September 2026. Unpublished draft-3 candidate work, extending the existing direct/delegated gateway. This is a delivery observation and collector-intake implementation; it does not establish downstream use, human assent, client receipt or full protocol release readiness. Frozen r3 artifacts remain unchanged.

## Construction and host responsibilities

```python
from profiles.free_gateway import FreeGateway
from profiles.gateway_reports import GatewayReporter
from profiles.authority_transport import ScopedCredential

gateway = FreeGateway(
    private_journal_path,
    binding=selected_service_binding, base_url=selected_service_base_url,
    transport=verified_https_transport, history=authority_history,
    credential=introspection_credential, clock=trusted_utc_clock,
    reporting=GatewayReporter(
        reporter_id=registered_gateway_reporter_id,
        key_id=registered_gateway_key_id,
        private_key=gateway_signing_key,
        credential=ScopedCredential(
            selected_service_origin, selected_service_path_prefix,
            explicitly_supplied_report_authorization,
        ),
    ),
)
```

The collector is the exact selected service identified by binding and base URL. The report credential is supplied separately and must have that exact origin and directory scope. The service authenticates it as the registered gateway account responsible for the admission, then verifies the registered gateway signing key. Reusing the account's secret is possible in the reference fixture; passing a different account does not transfer admission ownership. Credentials and private keys are absent from the journal, event, acknowledgement and object repr.

`reporting=None` explicitly retains legacy journal-only operation. Once a reporter identity is saved, reopening that journal with a different identity/key or with reporting disabled fails. Replacing a private key under the same key ID, switching service/base URL or changing collectors requires an explicit migration; this slice does not implement that migration or redirect queued material. Adding reporting to a prior journal does not fabricate events for old terminal records.

The HTTP host calls `admit(...)`, then `begin_delivery(request_id)` immediately before its protected response. Cached responses require the same fresh admission. After actual transport writes it calls:

```python
gateway.finish_delivery(
    request_id,
    bytes_written=observed_content_byte_count,
    complete=transport_completed,
    status=actual_http_status,
    observed_digest=hash_of_actual_written_content_or_none,
    representation_metadata={
        'media_type': 'text/plain',
        'media_parameters': {'charset': 'utf-8'},
        'languages': ['en'],
    },
    cache_status='miss',
)
results = gateway.publish_reports(limit=100)
```

`representation_metadata` may be null when not observed. Optional fields are content codings, independently observed decoded byte count/digest, a supported identity-coded single range, asset reference, and explicit ingress/boundary identifiers. The gateway uses the admission's exact method, resource, policy, agreement, action/purpose and delivery/request ID. `source=origin_observed`, `hop_role=end_client`, event and hop UUIDs, and timestamps are written by the trusted gateway. It cannot promote a client claim into observed delivery.

`bytes_written` means content bytes the hosting transport can attest it wrote; a library exception may leave the amount unknown to that host. The host must not pass intended buffer length as if it were measured, or invent zero when the count is unknown. If it cannot attest a count, it leaves the record unresolved `in_flight` for investigation. Socket-write completion does not prove that a remote client received, retained or used the bytes. No network/database transaction can eliminate the interval between sending bytes and durably recording the outcome.

The compatibility parameter `representation_digest` is a prepared **content-byte** digest, not a decoded-asset digest. It is used only after attested complete transport, with no range/206 response. Failed transport discards that parameter. Only `observed_digest` can identify a written prefix or range; unknown partial digests remain null. Coded content does not acquire a decoded digest unless independently supplied. Unsupported partials carry no decoded claim. HEAD/204/205/304 carry zero content bytes and no body digest. Full, partial, failed, HEAD, not-modified, error and unsupported-response events preserve the existing closed HTTP classification. An asset reference alone is not proof that a version's bytes were delivered.

## Durable state and recovery

The private SQLite journal uses `journal_mode=DELETE`, `synchronous=FULL`, immediate transactions and a closed connection per transaction. It pins service and reporter identities. Existing states remain:

| Journal state | Meaning | Publication |
| --- | --- | --- |
| `admitted` | Authenticated current service decision saved; no protected write started | No terminal report |
| `in_flight` | Single-use admission consumed immediately before transport | No fabricated completion |
| `sent` | Host attests terminal transport completion | One queued signed outcome |
| `partial` | Host attests incomplete/failed terminal transport | One queued signed failure outcome |

The terminal update and its signed `odexa-event+jws` outbox row commit in **one transaction**. Event ID, exact payload bytes, compact JWS and its randomized ES256 signature are created once and retained. A signing error, SQL failure or crash before commit leaves `in_flight` and no published terminal outcome. A crash after commit preserves both the terminal record and queued report. `sent` describes a completed HTTP transport, including a no-body/error response; the event's HTTP classification determines whether this was an asset delivery.

There is deliberately no automatic conversion of `admitted` or `in_flight` into successful delivery after restart. Operators can investigate unresolved records; neither a retry nor a background reconciliation invents missing byte evidence. No report publication happens inside the response-writing callback: `finish_delivery` only signs and commits locally. The host decides when a bounded drain runs; this module creates no background scheduler.

`publish_reports(limit=1..1000)` sends queued records at least once. Failures preserve the same event ID and exact JWS. A lost response or crash after collector intake is resolved by the collector's exact-payload deduplication returning its original signed acknowledgement. Concurrent drains may send duplicates but cannot replace the locally committed original acknowledgement; the collector must return identical original bytes. `report_records()` exposes the trusted local evidence rows for inspection/export, not a public endpoint. Completed rows are retained, not automatically deleted.

## Current authority and authenticated acknowledgement

For each attempt the gateway fetches the publisher's current `/odexa-service.json` and `/odexa.json`, applies durable authority history, and checks `receive_events`, the selected endpoint, service/issuer/delegation, resource/action/purpose scope and an active `odexa-event-record+jws` key. Reporting grants no access duration or payment authority. The exact authenticated snapshot is saved **before** transmitting the signed report.

The explicit POST goes only to `selected_base_url + 'events'`. Neither a policy reporting-duty endpoint nor a redirect can choose another collector. The HTTPS transport isolates the report credential to its configured origin/tenant path, checks current authority/history again after DNS/TLS immediately before HTTP transmission, accepts only status 201 or 200, and retains exact response bytes. There is no stale authority fallback or anonymous downgrade.

After the reply, the gateway fetches current publisher metadata again and rechecks its history and current collector/key authority. It verifies the intake signature, full selected-service binding, `origin_key_verified` assurance, authenticated reporter, exact original report payload and JWS, timestamp bounds, and exact authority context reference. Checks run after obtaining the journal transaction lock and again after cryptographic verification. The original verified intake JWS, verification time and current exact authority/policy/observation snapshot commit together with `acknowledged` state. Remote origin revocation and a local commit remain separate events: the guarantee is bounded by those recorded authenticated observations, not instantaneous global revocation.

A retry can return an acknowledgement referring to older publisher metadata. It is accepted only if those exact authority bytes were retained from this gateway's authenticated pre/post-send observations, and the referenced collector/key was authorized in that retained context. The old intake is not rewritten to cite a new authority revision. Current collector/key authorization is still required. Unknown historical context, an inactive old signer, an endpoint migration, withdrawn delegation, origin outage, wrong signature, altered report or forged source keeps the report queued with a bounded error class. This conservative archival boundary may require operator investigation when metadata changes during a lost reply; it is not silent acceptance of provider-supplied history. A retained observation demonstrates what was fetched at its recorded time, not that remote authority never changed between observations.

No signed report is sent to arbitrary duty endpoints, and no payment credential/mandate is created. The same selected-service delivery boundary can be used by the root's paid service through the shared introspection/report contracts, but this reporting module does not decide whether payment authorizes access.

## Executed checks and remaining verification

The child task executed all **7 local journal/semantics tests** successfully, including exact signed bytes across restart, unknown/known failed-prefix handling, range and no-body/coded classification, reporter isolation, signer/SQL rollback and an actual child-process exit after outbox insertion but before terminal commit. Root subsequently executed the full **15/15 gateway-reporting methods**, including all **8 actual TLS tests**, successfully. That network-enabled execution is root's result; this child task cannot bind loopback servers.

```sh
python3 -m unittest discover -s tests -p 'test_gateway_reports.py' -v
python3 -m unittest discover -s tests -p 'test_authority_transport.py' -v
```

The 8 new gateway TLS tests cover direct and two-origin publication, lost-acknowledgement exact retry/restart, origin outage/withdrawal before send, a stale pre-send decision, withdrawal after collector commit, wrong intake signature/reporter, and refusal to follow a changed tenant endpoint. The transport adds explicit `(200,201)` acceptance and trusted per-call JOSE-type selection; those changes preserve the default crypto registry. These are bounded reference checks, not an external security audit or certification.
