BEFORE_STATE (verified, with evidence):
* P060 PARTIAL/BROKEN. The registry persisted rights/provenance/health/
freshness to Postgres, but carried no authorization, no schedule, no
checkpoint, no schema and no owner distinct from created_by — and the
activation DoD was inverted: postgres-source-repository.ts wrote
`${input.status ?? "active"}`, so a source with no rights, no provenance
and no authorization was ACTIVE the instant it was registered. No
activation operation existed at all.
* P061 MISSING. services/api/sources/connectors.py had per-source fetch_*()
functions only — no lifecycle, no cursor, no resume, no health, no
conformance suite.
* P062 MISSING, already documented as such in
services/api/tests/test_known_gaps.py:10-19 (no CDC pipeline anywhere;
workers/pipeline.py has no persistence at all).
* P063 MISSING. schema_version existed as a free string; no comparison,
no policy, no versioned source schema.
* P064 PARTIAL. Rules/runs/issues/scores existed, but a failing rule only
opened an issue and the data flowed on. No quarantine anywhere.
* P065 DUPLICATE (confirmed). Two ER engines: src/er/entity-resolution-
service.ts (canonical) and services/api/workers/resolve.py (simplified,
still called by pipeline.py:110).
* P067 PARTIAL. Lineage was emitted to AUD0 but nothing here could ANSWER a
lineage question — no store, no query, no route.
* P068 MISSING, documented in test_known_gaps.py:44-52 ("no delivery-store
persistence layer exists to dedupe a backfill against").
BUILT (only what was PARTIAL/BROKEN/MISSING):
* P060 sql/007_ingestion_plane.sql adds authorization/schedule/schemas/
checkpoints/ledger/quarantine/lineage tables, owner_id, and flips the
opt_sources status DEFAULT to 'pending' so even a raw psql INSERT cannot
produce an unclassified-but-active source. SourceRegistryService gains
classifySource/activateSource/deactivateSource + authorization/schedule
accessors; registration NEVER activates (asking for status:"active" goes
through the gate, not around it); a service identity cannot authorize a
source. Routes: activate (GET classification / POST activate),
authorization, schedule.
* P061 src/connectors/connector.ts — all nine operations on one interface.
src/connectors/conformance.ts — a runnable suite (15 checks, no "skip"
verdict). HttpJsonConnector (real HTTP, opaque cursor) and
JsonlFileConnector (real file, line offsets, CDC deletes) both pass it.
The suite caught a real defect while being written: an API that drops
next_cursor at the end left the connector unable to resume, which is why
HttpJsonConnectorConfig has cursor_field.
* P062 src/types/ingestion.ts + src/ingestion/{envelope,ingestion-runner}.ts.
One envelope for batch/stream/cdc; durable per-(source,partition)
checkpoints; resume proven by killing a run inside the sink.
* P063 src/schema/{record-schema,compatibility,schema-registry}.ts. Two
enforcement points, both upstream of any consumer write: a discovery gate
before the first read, and per-record conformance before the page reaches
the sink. A producer cannot widen its own policy retroactively.
* P064 src/quality/quality-policy.ts composes the EXISTING ExpectationEngine
(not a second engine) and adds the missing consequence: block /
quarantine / warn, with quarantine durable and recoverable. No policy is
reported as "none_declared", never as a pass; an unexecutable expectation
counts as a violation, never a silent pass.
* P067 src/lineage/{lineage-edge,lineage-store}.ts + GET /api/0pt/lineage.
AUD0 stays the system of record; this is 0pt's own queryable projection.
* P068 opt_ingestion_ledger + IngestionRunner.backfill: bounded, idempotent
on repeat, leaves the live checkpoint alone, still re-emits real changes.
A BUG THIS PASS WROTE AND CAUGHT: the first IngestionRepository draft
combined the idempotency check and its recording into one atomic call
BEFORE the sink. The crash/resume test then delivered 7 of 10 records — the
page in flight when the sink died was already marked delivered, so the
resumed run skipped it as a duplicate. Permanent, silent data loss. Fixed by
splitting peekDeliveries/commitDeliveries and committing both the ledger and
the checkpoint only after the sink resolves.
NOT DONE, stated rather than deferred quietly:
* P065 is NOT met. resolve.py is still a second simplified production
engine. Closing it means rewriting it as an adapter over the canonical
service, which cannot be verified from this repo today: services/api is
not built by this repo's Dockerfile, api.0pt.app answers 404, and NO CI
workflow runs its pytest suite. The duplication is now stated at both
sites in code and the removal plan is in STATUS.md. The new ingestion
plane performs no ER, so it adds no third engine.
* P066 (Iceberg) untouched — ST0RE is owned by another workflow. Contract
read only.
* Delivery is AT-LEAST-ONCE, not exactly-once (no per-partition run lock).
* No CDC *source* exists; the contract is met, a CDC connector is not built.
* 0pt does not schedule itself — the schedule row is a declaration ACT0
would read.
VERIFIED: pnpm typecheck clean; pnpm test 365 passed / 25 skipped (was
311/18); pnpm build clean with all four new routes in the route table; lint
unchanged at 12 pre-existing errors. Real Postgres 16
(tests/p0/ingestion-plane-postgres.realinfra.test.ts, 7 passed) plus a real
`docker restart` of that container with an identical read-back through the
production code path — that is P060's "a restart preserves all sources",
measured.
DEPLOYMENT REALITY (curl, 2026-09-04): 0pt.app/api/health returns 200 from
Vercel with db:not_configured; 0pt.app/api/0pt/sources returns 404;
api.0pt.app/health returns 404; cd.yml still guards on a placeholder Coolify
app id. None of the capability layer runs in production. Nothing here is
LIVE_VERIFIED and STATUS.md says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.9 KiB
0pt ingestion plane (P060–P068)
Written 2026-09-04. Every claim here is backed by a test path or a recorded command; anything not verified says so. Read
STATUS.md's "P060-P068 ingestion plane" section for the classification table and the honest-gaps list — this document is the mechanism, that one is the status.
The shape
Source Registry Connector Ingestion Runner
(P060: who, what, (P061: nine (P062: one envelope,
under what rights, operations, one one durable checkpoint)
on what schedule) conformance suite)
│ │ │
│ classification gate │ discover / validate │ read ─┐
│ blocks activation │ connect / read │ │
▼ ▼ resume / backfill ▼ ▼
opt_sources records ───────────────▶ IngestionEnvelope
opt_source_rights │
opt_source_authorization ├─▶ P063 schema gate
opt_source_schedule ├─▶ P064 quality gate
opt_source_schemas ├─▶ P068 ledger (peek)
opt_ingestion_checkpoints ├─▶ SINK (caller-supplied)
├─▶ P068 ledger (commit)
├─▶ checkpoint (commit)
└─▶ P067 lineage edge
P060 — the registry, and the gate
A source now carries: source_id, tenant_id, project_id, source_type,
owner_id, an authorization record, a license/purpose rights record,
provenance (how the data is obtained), a schedule, a versioned
schema, a durable checkpoint, and a status.
status starts at pending. It was active before — both repository
implementations wrote input.status ?? "active" — which meant an
unclassified source went live the moment it was registered. activateSource
is now the only path to active, and it refuses unless ALL of these are on
record:
- an
owner_id(distinct fromcreated_by: who is accountable, not who typed it), - an unexpired authorization granted by an accountable actor
(
svc_*/system/automationare rejected — a source may not authorize itself), - rights with a
license_idand at least oneallowed_purpose, - provenance with a sourcing
method.
Schedule, schema and checkpoint describe HOW a source runs, not WHETHER it may, so they are deliberately not part of the gate.
The default is enforced in two places on purpose: in the repositories, and as
ALTER TABLE opt_sources ALTER COLUMN status SET DEFAULT 'pending', so a raw
INSERT from psql cannot produce an unclassified-but-active source either
(proved in the realinfra suite).
Routes: GET/POST /api/0pt/sources/{id}/activate (GET returns the
classification without changing anything), GET/PUT …/authorization,
GET/PUT …/schedule.
P061 — the connector contract
Nine operations on one interface: validate (pure, no IO, fail-closed),
connect (idempotent), discover, health (reports, never throws), read
(honours limit exactly), checkpoint, resume, backfill (bounded — an
open-ended range is refused), disconnect (idempotent, safe after an error).
src/connectors/conformance.ts is a plain async function, not a test file,
so the same suite runs from tests, from an operator script against a real
configured source, and (later) from a pre-activation check. There is no
"skip" verdict: a connector that cannot honestly serve a check FAILS it.
Two heterogeneous connectors pass it:
HttpJsonConnector |
JsonlFileConnector |
|
|---|---|---|
| transport | network, real HTTP | local filesystem |
| cursor | opaque server token, with a cursor_field fallback |
line offset it computes itself |
| exhaustion | server-reported | exact (the file is finite) |
| health | HTTP round trip | stat |
| backfill bounds | server-defined (sequence/timestamp) | line numbers |
| CDC deletes | no — it does not claim cdc |
yes, via _op |
The cursor_field fallback exists because the conformance suite caught a
real defect: an API that stops returning next_cursor at the end of a page
left the connector with nothing to resume from, so resume() silently
restarted at the beginning.
P062 — one envelope, one durable checkpoint
IngestionEnvelope is the single shape everything downstream sees:
mode (batch/stream/cdc), operation (upsert/insert/update/delete),
partition_key, record_key, schema_version, a monotonic sequence,
occurred_at/ingested_at, payload (null only for a delete) and
provenance (connector id + version + run id + cursor).
Ordering is the contract. Per page:
read/resume → normalize → schema conformance → quality gate
→ ledger PEEK → SINK → ledger COMMIT → checkpoint COMMIT
Both commits happen after the sink resolves. The two tempting shortcuts are both data-loss bugs, and one of them was written here first — see STATUS.md's "A real bug this pass wrote and then caught".
The resulting guarantee is at-least-once, not exactly-once.
envelope_id is a deterministic hash of
(source, partition, record_key, content) so a dedup-capable sink survives a
re-delivery.
P063 — schema evolution
RecordSchema is a small structural shape (field → type + required) plus a
CompatibilityPolicy (backward / forward / full / none).
checkCompatibility answers what breaks, in which direction, and why. It is
deliberately not a second JSON-Schema dialect: the question is "is version
N+1 of an UPSTREAM shape safe for consumers of version N?", which needs a
comparable structure, not a validator.
Two enforcement points, both upstream of any consumer write:
- the runner's discovery gate — a shape change that breaks the previous
version's declared policy fails the whole run before the first
read(); - per-record conformance — a source that drifted without redeclaring its schema is caught on the offending record, before the page reaches the sink.
A producer cannot widen its own policy: the PREVIOUS version's policy governs whether a change is allowed.
P064 — quality with consequences
QualityGate composes the existing ExpectationEngine (the Great
Expectations core subset already in this repo) and adds the missing half: a
declared on_violation action.
block— the page fails, and the checkpoint does not advance, so the bad page stays unconsumed rather than being stepped over.quarantine— failing envelopes are persisted whole toopt_quality_quarantine(recoverable, not merely counted); clean ones continue and the checkpoint advances.warn— everything continues, violations are counted and reported.
There is no default. With no policy the run report says
quality_policy: "none_declared" — an undeclared policy must never read as a
passed one. An expectation the local engine cannot execute counts as a
VIOLATION, never a silent pass.
P067 — queryable lineage
LineageEmitter keeps emitting OpenLineage to AUD0, and AUD0 remains the
system of record. LineageStore adds 0pt's own flattened projection — one
row per (run, input, output) with a deterministic edge_id — because
"emitted to AUD0" was never the same thing as "answerable from 0pt".
GET /api/0pt/lineage?tenant_id=…&output_name=0pt:dataset:ds_x # what fed it
GET /api/0pt/lineage?tenant_id=…&input_name=source/src_x # what it produced
An unfiltered dump is refused (400): that is an export, not a query.
P068 — bounded, idempotent backfill
opt_ingestion_ledger holds one row per (source_id, record_key) with the
last accepted content hash. A backfill runs through the same envelope,
schema, quality and ledger path as a normal run, and:
- never touches the live checkpoint — a backfill must not rewind or advance streaming ingestion;
- re-emits nothing on a repeat — every record comes back
duplicate; - still re-emits a genuine content change — idempotency is not amnesia;
- refuses an unbounded range at the connector contract level.
Running the real-infrastructure proof
docker run -d --name opt-ingestion-pgtest \
-e POSTGRES_USER=opt -e POSTGRES_PASSWORD=opt_test_pw -e POSTGRES_DB=opt_test \
-p 15434:5432 postgres:16-alpine
INGESTION_TEST_DATABASE_URL=postgres://opt:opt_test_pw@localhost:15434/opt_test \
pnpm vitest run tests/p0/ingestion-plane-postgres.realinfra.test.ts
The suite applies sql/002_source_registry.sql and
sql/007_ingestion_plane.sql itself (both are idempotent). It skips
honestly, never fabricating success, when the env var is unset.