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
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>
150 lines
6.9 KiB
TypeScript
150 lines
6.9 KiB
TypeScript
/**
|
|
* P063 DoD: "a breaking schema is detected BEFORE consumer corruption."
|
|
*
|
|
* Two levels, both tested here:
|
|
* - the pure comparison (checkCompatibility) per policy direction, and
|
|
* - the registry refusing to record a version that breaks the PREVIOUS
|
|
* version's declared policy — including the bypass where a producer tries
|
|
* to widen its own policy to "none" on the breaking version.
|
|
*
|
|
* The end-to-end "no consumer write happened" proof lives in
|
|
* tests/p0/ingestion-runner.test.ts, which asserts the sink was never called.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { checkCompatibility, conformanceErrors } from "@/src/schema/compatibility";
|
|
import { SchemaRegistry } from "@/src/schema/schema-registry";
|
|
import { InMemoryIngestionRepository } from "@/src/repositories/in-memory-ingestion-repository";
|
|
import { SiaxError } from "@/src/platform/errors";
|
|
import type { RecordSchemaFields } from "@/src/schema/record-schema";
|
|
|
|
const V1: RecordSchemaFields = {
|
|
org_number: { type: "string", required: true },
|
|
name: { type: "string", required: true },
|
|
employees: { type: "number", required: false },
|
|
};
|
|
|
|
describe("P063 compatibility rules", () => {
|
|
it("adding an OPTIONAL field is backward-compatible", () => {
|
|
const next = { ...V1, sni_code: { type: "string" as const, required: false } };
|
|
expect(checkCompatibility(V1, next, "backward").compatible).toBe(true);
|
|
});
|
|
|
|
it("adding a REQUIRED field breaks backward compatibility", () => {
|
|
const next = { ...V1, sni_code: { type: "string" as const, required: true } };
|
|
const verdict = checkCompatibility(V1, next, "backward");
|
|
expect(verdict.compatible).toBe(false);
|
|
expect(verdict.breaks[0].field).toBe("sni_code");
|
|
expect(verdict.breaks[0].detail).toContain("absent from every record written under the previous version");
|
|
});
|
|
|
|
it("adding a required field is FORWARD-safe — direction matters", () => {
|
|
const next = { ...V1, sni_code: { type: "string" as const, required: true } };
|
|
expect(checkCompatibility(V1, next, "forward").compatible).toBe(true);
|
|
expect(checkCompatibility(V1, next, "full").compatible).toBe(false);
|
|
});
|
|
|
|
it("removing a required field breaks BOTH directions", () => {
|
|
const next: RecordSchemaFields = { org_number: V1.org_number, employees: V1.employees };
|
|
expect(checkCompatibility(V1, next, "backward").compatible).toBe(false);
|
|
expect(checkCompatibility(V1, next, "forward").compatible).toBe(false);
|
|
});
|
|
|
|
it("removing an OPTIONAL field is backward-safe but not forward-safe", () => {
|
|
const next: RecordSchemaFields = { org_number: V1.org_number, name: V1.name };
|
|
expect(checkCompatibility(V1, next, "backward").compatible).toBe(true);
|
|
expect(checkCompatibility(V1, next, "forward").compatible).toBe(true);
|
|
});
|
|
|
|
it("a type change breaks every policy except an explicit 'none'", () => {
|
|
const next: RecordSchemaFields = { ...V1, employees: { type: "string", required: false } };
|
|
expect(checkCompatibility(V1, next, "backward").compatible).toBe(false);
|
|
expect(checkCompatibility(V1, next, "forward").compatible).toBe(false);
|
|
expect(checkCompatibility(V1, next, "full").compatible).toBe(false);
|
|
expect(checkCompatibility(V1, next, "none").compatible).toBe(true);
|
|
});
|
|
|
|
it("conformanceErrors catches a record that silently stopped matching its schema", () => {
|
|
expect(conformanceErrors(V1, { org_number: "556001-0001", name: "Alfa AB" })).toEqual([]);
|
|
expect(conformanceErrors(V1, { org_number: "556001-0001" })).toContain('required field "name" is missing');
|
|
expect(conformanceErrors(V1, { org_number: "556001-0001", name: "Alfa", employees: "ten" })).toContain(
|
|
'field "employees" is string, expected number',
|
|
);
|
|
expect(conformanceErrors(V1, { org_number: "1", name: "A", surprise: true })).toContain(
|
|
'unregistered field "surprise" appeared in the payload',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("P063 schema registry — versioning and refusal", () => {
|
|
function registry() {
|
|
return new SchemaRegistry(new InMemoryIngestionRepository());
|
|
}
|
|
|
|
it("the first registration is v1; an identical re-registration does not create v2", async () => {
|
|
const reg = registry();
|
|
const first = await reg.register({ source_id: "src_a", tenant_id: "t", fields: V1, compatibility: "backward" });
|
|
expect(first.schema.schema_version).toBe(1);
|
|
const again = await reg.register({ source_id: "src_a", tenant_id: "t", fields: V1, compatibility: "backward" });
|
|
expect(again.unchanged).toBe(true);
|
|
expect((await reg.list("src_a")).length).toBe(1);
|
|
});
|
|
|
|
it("a compatible change becomes v2", async () => {
|
|
const reg = registry();
|
|
await reg.register({ source_id: "src_b", tenant_id: "t", fields: V1, compatibility: "backward" });
|
|
const v2 = await reg.register({
|
|
source_id: "src_b",
|
|
tenant_id: "t",
|
|
fields: { ...V1, sni_code: { type: "string", required: false } },
|
|
compatibility: "backward",
|
|
});
|
|
expect(v2.schema.schema_version).toBe(2);
|
|
expect((await reg.getLatest("src_b"))?.schema_version).toBe(2);
|
|
});
|
|
|
|
it("a BREAKING change is refused and nothing is persisted", async () => {
|
|
const reg = registry();
|
|
await reg.register({ source_id: "src_c", tenant_id: "t", fields: V1, compatibility: "backward" });
|
|
const err = await reg
|
|
.register({
|
|
source_id: "src_c",
|
|
tenant_id: "t",
|
|
fields: { ...V1, sni_code: { type: "string", required: true } },
|
|
compatibility: "backward",
|
|
})
|
|
.catch((e: unknown) => e);
|
|
expect(err).toBeInstanceOf(SiaxError);
|
|
expect((err as SiaxError).code).toBe("CONFLICT");
|
|
expect((err as SiaxError).message).toContain("breaks the declared");
|
|
// Still only v1 on record — a refused registration writes nothing.
|
|
expect((await reg.list("src_c")).map((s) => s.schema_version)).toEqual([1]);
|
|
});
|
|
|
|
it("a producer cannot widen its OWN policy to 'none' on the breaking version", async () => {
|
|
const reg = registry();
|
|
await reg.register({ source_id: "src_d", tenant_id: "t", fields: V1, compatibility: "full" });
|
|
// The PREVIOUS version's policy governs — declaring "none" here must not help.
|
|
await expect(
|
|
reg.register({
|
|
source_id: "src_d",
|
|
tenant_id: "t",
|
|
fields: { ...V1, employees: { type: "string", required: false } },
|
|
compatibility: "none",
|
|
}),
|
|
).rejects.toThrow(/breaks the declared "full" compatibility policy/);
|
|
});
|
|
|
|
it("a source that explicitly declared 'none' opts out — recorded, never assumed", async () => {
|
|
const reg = registry();
|
|
await reg.register({ source_id: "src_e", tenant_id: "t", fields: V1, compatibility: "none" });
|
|
const v2 = await reg.register({
|
|
source_id: "src_e",
|
|
tenant_id: "t",
|
|
fields: { ...V1, employees: { type: "string", required: false } },
|
|
compatibility: "none",
|
|
});
|
|
expect(v2.schema.schema_version).toBe(2);
|
|
expect(v2.schema.compatibility).toBe("none");
|
|
});
|
|
});
|