Files
0pt/sql/007_ingestion_plane.sql
T
Simon AxelssonandClaude Opus 5 9267576b7c
CI (SIAX Cloud) / integration (push) Successful in 42s
CI (SIAX Cloud) / quality (push) Successful in 1m17s
CI (SIAX Cloud) / security (push) Successful in 46s
Security Scan (SIAX Cloud) / security (push) Successful in 31s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
feat(data-os): P060-P068 durable ingestion plane — source classification gate, connector contract, common envelope, schema evolution, quality enforcement, queryable lineage, idempotent backfill
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>
2026-09-04 17:03:14 +02:00

163 lines
9.0 KiB
SQL

-- 0pt ingestion plane (P060-P068).
--
-- Adds the durable state the Source Registry and the ingestion runner need
-- and that sql/002_source_registry.sql did NOT carry:
--
-- * opt_source_authorization -- P060 "authorization": the recorded decision
-- that this tenant may use this source at all. Separate from rights
-- (license/purpose) on purpose: rights say WHAT the data may be used for,
-- authorization says WHO signed off that 0pt may connect. Both are
-- required to activate.
-- * opt_source_schedule -- P060 "schedule": ingestion mode + cadence.
-- * opt_source_schemas -- P060 "schema" + P063 schema evolution:
-- one row per (source_id, schema_version), plus the compatibility policy
-- that governs the NEXT version.
-- * opt_ingestion_checkpoints -- P060 "checkpoint" + P062: the durable
-- resume point per (source_id, partition_key). This is the table that
-- makes "restart resumes without silent gaps" true rather than aspirational.
-- * opt_ingestion_ledger -- P068: idempotency ledger. One row per
-- (source_id, record_key, payload hash); a repeated backfill that
-- re-delivers the same record is recognised, not re-emitted.
-- * opt_quality_quarantine -- P064: where envelopes go when the declared
-- enforcement policy says "quarantine" instead of "block".
-- * opt_lineage_edges -- P067: the LOCAL, QUERYABLE projection of
-- the OpenLineage events 0pt already emits to AUD0. AUD0 remains the
-- system of record; this table exists because "emitted to AUD0" is not
-- the same thing as "queryable from 0pt", and the P067 DoD asks for the
-- latter.
--
-- Every statement is IF NOT EXISTS / idempotent so it can be applied to an
-- already-migrated database, matching sql/002_source_registry.sql's contract
-- (tests/p0/*.realinfra.test.ts apply the files themselves).
--
-- Ordering: requires sql/002_source_registry.sql (opt_sources) to have run.
-- ── P060 owner + the pre-activation status ────────────────────────────────
-- `created_by` (002) records who typed the registration; `owner_id` records
-- who is ACCOUNTABLE for the source afterwards. The activation gate requires
-- the latter, so they cannot be collapsed.
ALTER TABLE opt_sources ADD COLUMN IF NOT EXISTS owner_id TEXT;
CREATE INDEX IF NOT EXISTS opt_sources_owner_idx ON opt_sources (owner_id);
-- New sources are created 'pending' (see src/repositories/*-source-repository.ts).
-- The column default in 002 is 'active'; it is changed here so a plain INSERT
-- that omits `status` -- from any client, including psql -- cannot produce an
-- unclassified-but-active source. Rows that are already 'active' are left
-- exactly as they are: this migration does not retroactively deactivate
-- anything an operator has already approved.
ALTER TABLE opt_sources ALTER COLUMN status SET DEFAULT 'pending';
-- ── P060 authorization ────────────────────────────────────────────────────
-- One row per source. `basis` is the legal/contractual ground; `granted_by`
-- is the accountable actor id (never "system"); `evidence_ref` points at the
-- AUD0 evidence or 1p/contract artifact. `expires_at` is honoured by the
-- activation gate -- an expired authorization does not classify a source.
CREATE TABLE IF NOT EXISTS opt_source_authorization (
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
tenant_id TEXT NOT NULL,
basis TEXT NOT NULL, -- contract | public_register | consent | legitimate_interest | internal
granted_by TEXT NOT NULL, -- accountable actor id
granted_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
evidence_ref TEXT, -- aud0 evidence ref / contract artifact ref
notes TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS opt_source_authorization_tenant_idx ON opt_source_authorization (tenant_id);
-- ── P060 schedule ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS opt_source_schedule (
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
tenant_id TEXT NOT NULL,
mode TEXT NOT NULL, -- batch | stream | cdc
interval_seconds INT, -- batch/cdc poll cadence; NULL for continuous streams
cron TEXT, -- optional cron expression (documented, not executed here)
enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS opt_source_schedule_tenant_idx ON opt_source_schedule (tenant_id);
-- ── P060 schema + P063 schema evolution ───────────────────────────────────
-- `fields` is the record schema as JSON: {"<field>": {"type": "...",
-- "required": true|false}}. `compatibility` governs what the NEXT registered
-- version is allowed to change (see src/schema/compatibility.ts).
CREATE TABLE IF NOT EXISTS opt_source_schemas (
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
schema_version INT NOT NULL,
tenant_id TEXT NOT NULL,
fields JSONB NOT NULL,
compatibility TEXT NOT NULL, -- backward | forward | full | none
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
registered_by TEXT,
PRIMARY KEY (source_id, schema_version)
);
CREATE INDEX IF NOT EXISTS opt_source_schemas_tenant_idx ON opt_source_schemas (tenant_id);
-- ── P060 checkpoint + P062 durable resume ─────────────────────────────────
-- `cursor` is opaque to 0pt -- the connector defines it (an offset, an LSN,
-- a page token). `sequence` is 0pt's own monotonic envelope counter for the
-- partition, used to detect gaps on resume.
CREATE TABLE IF NOT EXISTS opt_ingestion_checkpoints (
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
partition_key TEXT NOT NULL,
tenant_id TEXT NOT NULL,
cursor TEXT,
sequence BIGINT NOT NULL DEFAULT 0,
records_committed BIGINT NOT NULL DEFAULT 0,
committed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source_id, partition_key)
);
CREATE INDEX IF NOT EXISTS opt_ingestion_checkpoints_tenant_idx ON opt_ingestion_checkpoints (tenant_id);
-- ── P068 backfill idempotency ledger ──────────────────────────────────────
-- A backfill/replay that re-delivers a record 0pt has already accepted must
-- be a no-op, not a duplicate. `payload_hash` distinguishes "same record,
-- same content" (skip) from "same record, new content" (an update, emitted).
CREATE TABLE IF NOT EXISTS opt_ingestion_ledger (
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
record_key TEXT NOT NULL,
tenant_id TEXT NOT NULL,
payload_hash TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivery_count BIGINT NOT NULL DEFAULT 1,
PRIMARY KEY (source_id, record_key)
);
CREATE INDEX IF NOT EXISTS opt_ingestion_ledger_tenant_idx ON opt_ingestion_ledger (tenant_id);
-- ── P064 quarantine ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS opt_quality_quarantine (
quarantine_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
tenant_id TEXT NOT NULL,
run_id TEXT NOT NULL,
record_key TEXT NOT NULL,
violations JSONB NOT NULL, -- [{expectation_type, kwargs, detail}]
envelope JSONB NOT NULL,
quarantined_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS opt_quality_quarantine_source_idx ON opt_quality_quarantine (source_id, quarantined_at DESC);
CREATE INDEX IF NOT EXISTS opt_quality_quarantine_tenant_idx ON opt_quality_quarantine (tenant_id);
-- ── P067 queryable lineage projection ─────────────────────────────────────
-- One row per (run, input, output) edge. `edge_id` is deterministic
-- (sha256 of the tuple) so a re-emitted idempotent run does not duplicate
-- edges -- the same dedup property LineageEmitter relies on in AUD0.
CREATE TABLE IF NOT EXISTS opt_lineage_edges (
edge_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
run_id TEXT NOT NULL,
capability_id TEXT NOT NULL,
input_namespace TEXT NOT NULL,
input_name TEXT NOT NULL,
input_ref TEXT,
output_namespace TEXT NOT NULL,
output_name TEXT NOT NULL,
output_ref TEXT,
event_time TIMESTAMPTZ NOT NULL,
correlation_id TEXT
);
CREATE INDEX IF NOT EXISTS opt_lineage_edges_output_idx ON opt_lineage_edges (tenant_id, output_name);
CREATE INDEX IF NOT EXISTS opt_lineage_edges_input_idx ON opt_lineage_edges (tenant_id, input_name);
CREATE INDEX IF NOT EXISTS opt_lineage_edges_run_idx ON opt_lineage_edges (tenant_id, run_id);