Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main, python-adcp-sdk-setup]
pull_request:
branches: [main, conductor/reporting-webhook-activity-1168b]
branches: [main, conductor/reporting-webhook-activity-1168b, conductor/reporting-status-notifications-1168c]

# Default @adcp/sdk runner alias for storyboard jobs. Tracks the current
# stable @adcp/sdk release via the ``latest`` npm dist-tag.
Expand Down Expand Up @@ -61,7 +61,18 @@ jobs:
test:
name: Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 30
# The job ceiling is not the suite's budget: it also has to absorb
# checkout, Python setup, the editable [dev] install and, on 3.12 only,
# ruff/mypy/mypy --strict/the type-ignore contract before pytest starts,
# then post-job cleanup after it ends. On an ubuntu-latest runner that
# pre-test work is ~1m30s and the plain suite is ~16m, but 3.12 adds
# coverage tracing on ~9.4k tests: at 30 minutes that leg was cancelled
# mid-run at 99% with no failing test, which reports as a red matrix and
# hides real signal. Bound the suite itself below (so a hang fails one
# named step with its own message instead of silently taking the job), and
# leave the job enough room that step bound plus setup and cleanup still
# fit with margin for runner variance.
timeout-minutes: 60
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
Expand Down Expand Up @@ -91,18 +102,24 @@ jobs:

- name: Run adopter type-check suite
if: matrix.python-version == '3.12'
run: mypy --strict tests/type_checks/ examples/reporting_webhook_activity.py examples/reporting_status_notifications.py
run: mypy --strict tests/type_checks/ examples/reporting_webhook_activity.py examples/reporting_status_notifications.py examples/reporting_destination_writer.py

- name: Enforce adopter type-check fixture contract
if: matrix.python-version == '3.12'
run: python scripts/check_type_ignore_contract.py

# Bounded well above the observed ~16m (plain) and ~30m (coverage) runs
# so ordinary variance never trips it, and well below the job ceiling so
# a genuinely stuck suite still fails *this* step with a timeout rather
# than being cancelled as a whole job.
- name: Run tests
if: matrix.python-version != '3.12'
timeout-minutes: 45
run: pytest tests/ -v

- name: Run tests with coverage
if: matrix.python-version == '3.12'
timeout-minutes: 45
run: pytest tests/ -v --cov=src/adcp --cov-report=term-missing

pg-conformance:
Expand Down Expand Up @@ -223,12 +240,13 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Fetch exact reviewed A and B compatibility artifacts
- name: Fetch exact reviewed A, B and C compatibility artifacts
timeout-minutes: 1
run: |
git fetch --no-tags --depth=1 origin \
17ee407ae3978c8a2bb54437287afbf9dafb8130 \
0f34c666ac1961e9832fce43ef0ef6937b3c1dde
0f34c666ac1961e9832fce43ef0ef6937b3c1dde \
967b6e286301d7e5d089aea6fdbb90bea8ee5a16

- name: Set up Python 3.12
uses: actions/setup-python@v6
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ include README.md
include LICENSE
include MIGRATION*.md
recursive-include src/adcp py.typed
recursive-include src/adcp/reporting/materializer/assets *.json
# Bundled AdCP JSON schemas. ``scripts/bundle_schemas.py`` mirrors
# ``schemas/cache/`` into ``src/adcp/_schemas/`` before ``python -m
# build`` so the validator ships with the wheel. Keep distributions on
Expand Down
177 changes: 177 additions & 0 deletions docs/reporting-destination-writer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Verified destination I/O — #1167B1

**B1 of B1/B2**, composed with integrated #1168C at
`967b6e286301d7e5d089aea6fdbb90bea8ee5a16`. Refs #1167.

B1 supplies immutable public contracts, whole-history revision selection,
SDK-owned source/destination verification, and a deterministic development
destination. Import from `adcp.reporting.materializer` and
`adcp.reporting.revision_selection`; PostgreSQL is optional. The strict
[adopter example](../examples/reporting_destination_writer.py) accepts existing
frozen ledger records and returns verification observations.

There is no durable materializer, discovery queue, lease manager, retry
allocator, final outcome transaction, or materializer migration in B1.
`ReferenceReportingDestinationWriter.production_eligible` is always `False`,
including at the type level. It cannot be promoted by configuration or
subclassing. Configuring it, completing its readback, or freezing a destination
binding never advertises `managed_delivery`, `reconciled_billing`, or
`reporting.delivery_ready`. The real outbox readiness helper suppresses that
claim until B2 can prove a complete durable materializer. The producer's `extra`
argument rejects SDK-owned task, tier and notification keys.

## Trusted contracts and lifecycle

`ReportingDestinationRequest` includes the exact account and canonical consumer
(including HTTPS BuyerAgent identities), configuration generation, trusted
destination and binding references, binding fingerprint, obligation, revision,
materialization ID and attempt. Its verification key includes the complete
frozen definition/schema, canonicalization and capability tuples. Aliases must
be resolved by trusted middleware before this boundary. Credentials are never
protocol arguments, request fields, locators, verification records or metadata.

`ReportingWriterCapability` closes method, transport, format, profile, readback
path, immutable-location/native-version mode, SHA-256 checksum and conditional
or idempotent write semantics. An unsupported tuple fails before resolver or
writer I/O. The immutable registry holds explicitly installed contract bytes;
it has no registration mutation, plugin callback or network fallback.

A resolver synchronously constructs an **unopened** SDK
`ReportingDestinationSession`. Resource acquisition and fresh authorization
belong in `_open`; all credentials and partial acquisitions belong to that
redacted, non-persistable session. `_close` must release every acquired resource
and any adopter spool, including after an interrupted `_open`. Override the
protected hooks and I/O methods, preserving the SDK-owned context manager,
redaction and exactly-once close. Read methods must resolve locators inside the
session's trusted destination namespace and reject another tenant's paths.

Write and readback open separate sessions and independently authorize the same
frozen request, so rotation/revocation between phases is observable. The SDK
checks the resolved request before opening and before I/O. `ReportingIOContext`
carries an absolute UTC deadline, cancellation event and optional service-owned
heartbeat checkpoint. It uses Python-3.10-compatible primitives, joins canceled
tasks, shields bounded cleanup and re-raises a clean `CancelledError`. The
optional heartbeat is a call boundary; B1 starts no lease-extension loop.

Writers receive SDK-produced immutable canonical row bytes inside
`ReportingPreparedRevision`, never mutable row dictionaries or arbitrary
metadata. They return `ReportingDestinationLocator` claims. Counts, checksums,
manifest hashes and native commit IDs supplied by a writer establish where and
what to read; they cannot establish verification.

`ReportingWriterFailure` contains only a closed code, retry instruction,
retry-after seconds and external-effect state (`not_started`, `applied`,
`unknown`). Unexpected provider failures become closed diagnostics without
provider exception chains. Do not construct messages from provider text or
log session internals, signed URLs, credentials or provider bodies.

The external idempotency identity includes all tenant, consumer, binding,
revision and attempt coordinates. Resuming the same pending attempt preserves
it; equal attempt numbers on different revisions do not collide. Unknown
effects cannot request a new attempt. **Public foundation persistence still
allows N+1 after any immutable terminal outcome.** B2's autonomous retry rule
will be narrower: N+1 only after its own known terminal failure, never while N
is pending. Consumer receipt rejection is unchanged. Before activating B2,
drain legacy materialization writers and explicitly recover/import legacy
pending identities; their external-effect history cannot be inferred.

## Installed canonicalizer and destination verification

The small built-in implementation supports pinned `adcp_jcs_rows_v1` contracts,
Draft 2020-12 schemas with local fragment references, direct sum metrics and
complete integer/fixed-scale decimal control totals. Decimal values are strings;
JSON integers must be exactly typed and within the JavaScript safe range.
The pinned schema's `x-adcp-control-total` annotation specifies value type,
optional unit and decimal scale. All declared metrics must be represented.
The reference definition, row schema and canonicalization golden vectors are
bundled package assets with exact byte hashes in both distribution paths.

This deliberately bounded JSON subset rejects floats, subclasses, tuples,
Decimal/datetime/bytes/set values, duplicate JSON members, invalid UTF-8 and lone
surrogates. It preserves Unicode without normalization and applies JCS UTF-16
member ordering. Primary-key row ordering is by canonical scalar-key-array
bytes; duplicate primary keys fail. Golden vectors must test empty content,
nontrivial row ordering and member ordering. SHA-256 hexadecimal evidence is
validated and compared semantically, accepting uppercase and producing lowercase.

Preparation reads **every** frozen source page, including zero rows and 501+
rows, rederives the Core digest and the canonical digest, and recomputes typed
totals before destination authorization. SDK source cursors bind revision and
offset; custom row readers return the same `ReportingRowPage` identity/cursor
contract. Both bundled ledger stores now reject a cursor issued for another
revision and any `read_revision_rows` page size outside 1..500 with
`INVALID_CURSOR` / `INVALID_PAGE_SIZE`; a caller that paged in larger windows
must split its walk. Stable totals, cursor progress, cycles, `has_more` pairing
and final count are enforced. Source/destination walks bound bytes, recursive
items, nesting, rows, pages, objects and chunks.

A retained Core `ReportingDefinitionBinding` is only loosely constrained, so an
obligation may carry a definition the strict verification key cannot express --
a versioned query URI, an uppercase or short digest, a legacy dialect or schema
version. Preparation, write and readback answer that with the closed
`BINDING_MISMATCH` failure rather than a raw `ValueError`.

Readback independently verifies every logical destination row in order. File
verification also reads the exact manifest bytes and its closed schema,
identities, period, creation time, complete typed totals, ordered object
inventory, and every streamed JSONL object's checksum, length, row count and
content. Native verification observes the pinned version, location and required
consumer/destination path before and after all pages; each page repeats that
version. A native commit ID alone cannot satisfy canonical-digest verification.

| Method | B1 reference format | Supported profiles | Required readback |
| --- | --- | --- | --- |
| File transfer | JSONL, uncompressed | canonical digest, manifest checksums | logical pages + exact manifest + every object |
| Dataset share | logical typed rows | canonical digest, native commit | representative-consumer rows + pinned native observations |
| Warehouse materialization | logical typed rows | canonical digest, native commit | destination rows + pinned native observations |

The verifier returns `ReportingVerifiedDestination` only after these reads.
Corruption returns a closed failure and no reusable verified result. The public
foundation's materialization transition validator checks retained claims; it is
not an independent destination reader. Its persisted `MaterializationFailure`
variants and record shapes are unchanged.

## Current revision and B2 finish seam

The neutral selector returns `selected`, `not_ready` or `corrupt`. It validates
ownership, duplicate IDs, every predecessor, finality edges, connected snapshot
history, forks, cycles and multiple officials before selecting. Only empty
history or absence of a required official is ordinary not-ready. A unique
official wins over an intact retained snapshot chain; otherwise snapshot
finality selects its one unsuperseded leaf. Readability and materialization
availability never select a revision or allow fallback.

Core health, producer acquisition, status projection/validation/lifecycle,
consumer planning and reconciliation use that selector. Producer corruption
fails before source or adapter I/O. Status emits a stable `HISTORY_UNAVAILABLE`
issue. The exported `current_required_revision(...) -> record | None` remains
source compatible; internal selection consumes the typed result.

`validate_materialization_target` is a pure seam for B2's locked finish path.
It checks the selected/readable exact revision and frozen binding against the
prepared input. It provides no transaction or fencing claim. B2 must reselect
on the same account-locked connection and co-commit immutable outcome,
reconciliation feed, work acknowledgment, C dirty and readiness notification.
A stale-after-I/O attempt must close as a compatible public safe failure with
its richer reason isolated in B2 state; B1 does not add a persisted
`CURRENT_REVISION_CHANGED` variant. Neither B1 nor B2 merges autonomously.

## C selector-epoch cutover

B1's only SQL addition is the separately manifested **C checkpoint** migration
`reporting_status_selector_version.sql`. See the
[C rollout instructions](reporting-status-notifications.md#selector-epoch-cutover-1167b1).
Stop and drain old C projectors and sweepers before enabling v2 turns. A/B/C
Core and notification writers remain compatible; legacy materialization writers
must separately be drained before B2 activation.

The frozen-C process gates exercise populated checkpoints, old `claim_due`,
old schema recreation, competing v2 projectors/sweepers, pool-local marker
cleanup and retained physical rows. Shared memory/PG vectors cover interrupted
fence, checkpoint/event and final-mark commits, ordered boundaries, late clocks,
retained scopes, unchanged fingerprints and once-only restart convergence.

The C control is the integrated rc.6 artifact above, including exact waiver
bindings and locale-independent catalog validation. These controls do not
qualify pre-`967b6e28` C binaries, including the earlier `ea150fab` snapshot.
The existing pre-`17ee407a` A and pre-`0f34c666` B rolling limits remain.
53 changes: 52 additions & 1 deletion docs/reporting-status-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,63 @@ PostgreSQL. PostgreSQL exports load lazily and construction without the extra
raises an actionable `adcp[pg]` installation hint.

`PgStatusNotificationStore.create_schema()` executes the six existing ledger/A/B
steps plus `reporting_status_notifications.sql` in one serialized transaction.
steps plus `reporting_status_notifications.sql` and
`reporting_status_selector_version.sql` in one serialized transaction.
External migration tools must execute that same chain atomically. The original
`required_schema.json`, A/B objects, functions and constraints are unchanged.
`required_status_schema.json` independently validates C status and C activity
objects. Missing C DDL suppresses status without changing B readiness.

## Selector epoch cutover (#1167B1)

B1 changes whole-history revision selection and directional feed issue scopes.
Install the additive checkpoint migration, then **stop and drain old C
projectors and sweepers before scheduling v2 turns**. Core/source, A/B outbox and
C HTTP/activity writers remain usable. Installing schema alone does not fence
old C; account cutover is explicit, persisted and separate from its completion.

Drive `ReportingStatusProjector.rebuild_once()` (or
`ReportingStatusService.rebuild_selector_once()`) until idle. This uses indexed
discovery of every populated stale account compatible with the store's original
escalation policy; no adopter account list or full periodic scan is needed.
The existing service's `drain()` includes these turns. Use the same escalation
policy as the original baseline; an explicitly targeted mismatched policy fails
closed. Ordinary `project_one(account_id=...)` also resumes that account's
interrupted cutover.

Each first turn takes the existing account advisory lock, locks its checkpoints
and commits a checkpoint-local v2 writer floor plus an account transitioning
policy. A separate `selector_semantics_version` remains stale until projection
has committed. The new guard examines only the checkpoint and a
transaction-local v2 marker, avoiding an account-row lookup from old row-only
due claims. Old pending lease identities are retained, but old claims,
completions, projectors and readiness fail closed after the fence. Migration
does not wait for those leases to expire. Transaction-local markers are cleared
when connections return to the pool. Old named guards and their manifests are
unchanged, so old `create_schema()` cannot remove the independent v2 guard.

Subsequent short transactions drain captured source boundaries in `through`
order, replay overdue semantic deadlines chronologically, then project current
state and mark the account complete atomically. Checkpoint/event failure rolls
back the entire turn. The scope union includes all retained checkpoints,
including those no longer returned by current scope discovery; absent source
history has a stable `HISTORY_UNAVAILABLE` result and no obsolete due deadline.

No baseline, scope key, event, queue or activity history is deleted/reset. The
six-column scope identity, generation, baseline highwater, dirty cursor and
leases retain their meaning. Selector epoch is non-key metadata; canonical
fingerprints retain `version: 1`. Unchanged canonical health/issues only advance
the selector epoch and emit no event. Changed topology or issue membership
emits the corrected status with its existing previous health and next monotone
generation. Newly baselined accounts start at v2 without migration events.

Readiness requires current schema, baseline, target epoch and zero stale or
incomplete checkpoint migrations. Account transitions are isolated. The memory
reference imports old shared-state images as v1 and performs the same restartable
transition. These objects and `required_status_selector_schema.json` belong only
to C checkpoint semantics; B1 adds no materializer persistence. See
[B1 destination I/O](reporting-destination-writer.md) for the B2 rollout dependency.

Default-off Core lifecycle writes also work before the A outbox migration. That
schema has no issue-scope table: reads derive scope from retained status evidence
until migration makes scope persistence available. Notification-enabled writers
Expand Down
Loading
Loading