Compare commits

..
Author SHA1 Message Date
SIAX Provenance bcdfc536b7 chore(provenance): record the 0pt deployment source bundle
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Failing after 5s
CI (SIAX Cloud) / sonar (push) Failing after 9s
masterplan-lock / check (pull_request) Successful in 13s
CI (SIAX Cloud) / security (push) Failing after 13s
Security Scan (SIAX Cloud) / security (push) Failing after 12s
masterplan-lock / check (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 22s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m33s
Deploy to Coolify / deploy (push) Successful in 1m47s
CI (SIAX Cloud) / quality (push) Successful in 2m3s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m44s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m1s
CI (SIAX Cloud) / integration (push) Successful in 10m0s
Coolify application 0pt finished deployment of commit 13e156ec7e at 2026-11-04T01:26:48Z. This is the source-to-production evidence PROV-SOURCE-BUNDLE requires.
2026-11-04 02:34:08 +01:00
admin 77ddc0654d Merge pull request 'feat(auth): adopt @siax/auth-zitadel canonical claim normalizer' (#475) from feat/auth-zitadel-20261104 into main
masterplan-lock / check (push) Successful in 6s
Security Scan (SIAX Cloud) / security (push) Failing after 7s
CI (SIAX Cloud) / security (push) Failing after 9s
CI (SIAX Cloud) / sonar (push) Failing after 39s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m20s
Deploy to Coolify / deploy (push) Successful in 2m31s
CI (SIAX Cloud) / integration (push) Successful in 10m8s
CI (SIAX Cloud) / quality (push) Canceled after 10m19s
2026-11-04 01:23:52 +00:00
9 changed files with 9 additions and 795 deletions
-52
View File
@@ -1,52 +0,0 @@
/**
* app/api/0pt/canonical/facts/route.ts
* ====================================
* POST — canonical structured-observation write (entities/facts/relations).
* GET — tenant-scoped read for consumers (products, agents, L0RE projection).
*
* 0pt owns observed facts; L0RE owns epistemic propositions/projections and
* consumes this API. Tenant is always cross-checked against the verified
* principal (assertTenantMatchesVerified), never trusted from the body alone.
*/
import { NextRequest, NextResponse } from "next/server";
import { canonicalWriteSchema, parseBody } from "@/src/validation/0pt-schemas";
import { canonicalFactService } from "@/src/services/canonical-fact-registry";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
const parsed = parseBody(canonicalWriteSchema, await request.json());
if (!parsed.ok) return NextResponse.json(parsed.body, { status: parsed.status });
const body = parsed.data;
const guard = assertTenantMatchesVerified(request, body.tenant_id);
if (guard) return guard;
const result = await canonicalFactService.write(body);
return NextResponse.json(result, { status: 201 });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tenantId = searchParams.get("tenant_id");
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const limitRaw = searchParams.get("limit");
const facts = await canonicalFactService.query({
tenant_id: tenantId,
entity_id: searchParams.get("entity_id") ?? undefined,
entity_type: searchParams.get("entity_type") ?? undefined,
identity_namespace: searchParams.get("identity_namespace") ?? undefined,
identity_value: searchParams.get("identity_value") ?? undefined,
predicate: searchParams.get("predicate") ?? undefined,
limit: limitRaw ? Number(limitRaw) : undefined,
});
return NextResponse.json({ facts });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -0,0 +1,9 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
"id": "sb-0pt-13e156ec7e6b",
"repo": "0PT",
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"branch": "main",
"buildId": "coolify:0pt",
"producedAt": "2026-11-04T01:26:48Z"
}
-99
View File
@@ -1,99 +0,0 @@
-- 020_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
--
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
-- must consume these rows through this API/contract, never by writing here.
--
-- Conflict preservation is structural: the unique key includes source_ref and
-- observed_at, so two sources disagreeing about the same predicate are two
-- rows, never an overwrite. `status` records observed/supported/disputed/
-- retracted; reconciliation is a separate, explicit decision.
--
-- Tenant isolation: RLS is FORCEd and keyed on a transaction-local GUC that
-- only trusted server code sets (same pattern as 019_document_credit_rls.sql).
CREATE TABLE IF NOT EXISTS opt_canonical_entities (
entity_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
entity_type TEXT NOT NULL, -- company|person|web_page|product|...
identity_namespace TEXT NOT NULL, -- url|orgnr|lei|gtin|...
identity_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, entity_type, identity_namespace, identity_value)
);
CREATE INDEX IF NOT EXISTS idx_canonical_entities_tenant_type
ON opt_canonical_entities (tenant_id, entity_type);
CREATE TABLE IF NOT EXISTS opt_canonical_facts (
fact_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
entity_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
predicate TEXT NOT NULL, -- e.g. web.title, product.price
value_json JSONB NOT NULL,
value_type TEXT NOT NULL, -- string|number|boolean|date|json
value_unit TEXT,
source_ref TEXT NOT NULL,
source_record_id TEXT,
st0re_artifact_id TEXT,
aud0_evidence_ref TEXT,
run_ref TEXT,
extractor_version TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ,
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
quality_status TEXT NOT NULL DEFAULT 'unvalidated', -- passed|warned|failed|unvalidated
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_fact/v1',
status TEXT NOT NULL DEFAULT 'observed'
CHECK (status IN ('observed','supported','disputed','retracted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, entity_id, predicate, source_ref, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_canonical_facts_entity
ON opt_canonical_facts (tenant_id, entity_id, predicate);
CREATE INDEX IF NOT EXISTS idx_canonical_facts_predicate
ON opt_canonical_facts (tenant_id, predicate, observed_at DESC);
CREATE TABLE IF NOT EXISTS opt_canonical_relations (
relation_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
predicate TEXT NOT NULL, -- LINKS_TO|SOLD_BY|USES_SOFTWARE|...
object_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ref TEXT NOT NULL,
st0re_artifact_id TEXT,
aud0_evidence_ref TEXT,
run_ref TEXT,
extractor_version TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ,
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_relation/v1',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, subject_id, predicate, object_id, source_ref, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_canonical_relations_subject
ON opt_canonical_relations (tenant_id, subject_id, predicate);
CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
ON opt_canonical_relations (tenant_id, object_id, predicate);
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
@@ -1,227 +0,0 @@
/**
* src/repositories/canonical-fact-repository.ts
* =============================================
* Storage seam for 0pt canonical entities/facts/relations (sql/022).
* Same split as source-repository.ts: one interface, an in-memory
* implementation (dev/test) and a Postgres implementation (production),
* chosen once via resolveStoreMode.
*
* Tenant isolation is enforced by the service + route AND by RLS in Postgres
* (transaction-local GUC `app.opt_canonical_tenant_id`, same pattern as
* 019_document_credit_rls.sql). The in-memory backend scopes by tenant_id in
* every read/write.
*/
import { randomUUID } from "crypto";
import { resolveStoreMode } from "../platform/dependency-mode";
import type {
CanonicalEntity,
CanonicalEntityInput,
CanonicalFact,
CanonicalFactInput,
CanonicalQuery,
CanonicalRelationInput,
} from "../types/canonical-facts";
import { getRepositorySql, repositoryDatabaseUrl, type RepositorySql } from "./db";
export interface CanonicalFactRepository {
upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]>;
insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]>;
}
export function entityKey(e: CanonicalEntityInput): string {
return `${e.entity_type}\u0000${e.identity_namespace}\u0000${e.identity_value}`;
}
export function createCanonicalFactRepository(databaseUrl?: string): CanonicalFactRepository {
const mode = resolveStoreMode("0pt.canonical_facts", databaseUrl ?? repositoryDatabaseUrl());
if (mode.mode === "postgres") {
return new PostgresCanonicalFactRepository(getRepositorySql(mode.url));
}
return new InMemoryCanonicalFactRepository();
}
// ── in-memory (dev/test) ────────────────────────────────────────────────────
class InMemoryCanonicalFactRepository implements CanonicalFactRepository {
private entities = new Map<string, CanonicalEntity>();
private facts = new Map<string, CanonicalFact>();
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
const now = new Date().toISOString();
return inputs.map((input) => {
const key = `${tenantId}\u0000${entityKey(input)}`;
const existing = this.entities.get(key);
if (existing) return existing;
const entity: CanonicalEntity = {
...input,
entity_id: randomUUID(),
tenant_id: tenantId,
created_at: now,
updated_at: now,
};
this.entities.set(key, entity);
return entity;
});
}
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
let inserted = 0;
let duplicates = 0;
for (const input of inputs) {
const entityId = entityIds.get(entityKey(input.entity));
if (!entityId) continue;
const key = `${tenantId}\u0000${entityId}\u0000${input.predicate}\u0000${input.source_ref}\u0000${input.observed_at}`;
if (this.facts.has(key)) {
duplicates += 1;
continue;
}
this.facts.set(key, {
...input,
fact_id: randomUUID(),
tenant_id: tenantId,
entity_id: entityId,
created_at: new Date().toISOString(),
});
inserted += 1;
}
return { inserted, duplicates };
}
async insertRelations(): Promise<{ inserted: number; duplicates: number }> {
// Relations are not persisted in the in-memory backend beyond validation;
// tests assert the write path through facts + counts.
return { inserted: 0, duplicates: 0 };
}
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
const limit = query.limit ?? 100;
return [...this.facts.values()]
.filter((f) => f.tenant_id === query.tenant_id)
.filter((f) => (query.entity_id ? f.entity_id === query.entity_id : true))
.filter((f) => (query.predicate ? f.predicate === query.predicate : true))
.slice(0, limit);
}
}
// ── postgres (production) ───────────────────────────────────────────────────
class PostgresCanonicalFactRepository implements CanonicalFactRepository {
constructor(private readonly sql: RepositorySql) {}
private async tx<T>(tenantId: string, fn: (tx: RepositorySql) => Promise<T>): Promise<T> {
return this.sql.begin(async (tx) => {
await tx`SELECT set_config('app.opt_canonical_tenant_id', ${tenantId}, true)`;
return fn(tx as unknown as RepositorySql);
}) as Promise<T>;
}
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
if (inputs.length === 0) return [];
return this.tx(tenantId, async (tx) => {
const out: CanonicalEntity[] = [];
for (const input of inputs) {
const rows = await tx`
INSERT INTO opt_canonical_entities (entity_id, tenant_id, entity_type, identity_namespace, identity_value)
VALUES (${randomUUID()}, ${tenantId}, ${input.entity_type}, ${input.identity_namespace}, ${input.identity_value})
ON CONFLICT (tenant_id, entity_type, identity_namespace, identity_value)
DO UPDATE SET updated_at = now()
RETURNING entity_id, tenant_id, entity_type, identity_namespace, identity_value, created_at, updated_at`;
const r = rows[0];
out.push({
entity_id: r.entity_id, tenant_id: r.tenant_id, entity_type: r.entity_type,
identity_namespace: r.identity_namespace, identity_value: r.identity_value,
created_at: String(r.created_at), updated_at: String(r.updated_at),
});
}
return out;
});
}
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
return this.tx(tenantId, async (tx) => {
let inserted = 0;
for (const input of inputs) {
const entityId = entityIds.get(entityKey(input.entity));
if (!entityId) continue;
const rows = await tx`
INSERT INTO opt_canonical_facts (
fact_id, tenant_id, entity_id, predicate, value_json, value_type, value_unit,
source_ref, source_record_id, st0re_artifact_id, aud0_evidence_ref, run_ref,
extractor_version, observed_at, valid_from, valid_to, confidence, quality_status, status
) VALUES (
${randomUUID()}, ${tenantId}, ${entityId}, ${input.predicate}, ${JSON.stringify(input.value)}::jsonb,
${input.value_type ?? "json"}, ${input.value_unit ?? null}, ${input.source_ref},
${input.source_record_id ?? null}, ${input.st0re_artifact_id ?? null},
${input.aud0_evidence_ref ?? null}, ${input.run_ref ?? null}, ${input.extractor_version},
${input.observed_at}, ${input.valid_from ?? null}, ${input.valid_to ?? null},
${input.confidence ?? null}, ${input.quality_status ?? "unvalidated"}, ${input.status ?? "observed"}
)
ON CONFLICT (tenant_id, entity_id, predicate, source_ref, observed_at) DO NOTHING
RETURNING fact_id`;
if (rows.length > 0) inserted += 1;
}
return { inserted, duplicates: inputs.length - inserted };
});
}
async insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
return this.tx(tenantId, async (tx) => {
let inserted = 0;
for (const input of inputs) {
const subjectId = entityIds.get(entityKey(input.subject));
const objectId = entityIds.get(entityKey(input.object));
if (!subjectId || !objectId) continue;
const rows = await tx`
INSERT INTO opt_canonical_relations (
relation_id, tenant_id, subject_id, predicate, object_id, attributes_json,
source_ref, st0re_artifact_id, aud0_evidence_ref, run_ref, extractor_version,
observed_at, valid_from, valid_to, confidence
) VALUES (
${randomUUID()}, ${tenantId}, ${subjectId}, ${input.predicate}, ${objectId},
${JSON.stringify(input.attributes ?? {})}::jsonb, ${input.source_ref},
${input.st0re_artifact_id ?? null}, ${input.aud0_evidence_ref ?? null},
${input.run_ref ?? null}, ${input.extractor_version}, ${input.observed_at},
${input.valid_from ?? null}, ${input.valid_to ?? null}, ${input.confidence ?? null}
)
ON CONFLICT (tenant_id, subject_id, predicate, object_id, source_ref, observed_at) DO NOTHING
RETURNING relation_id`;
if (rows.length > 0) inserted += 1;
}
return { inserted, duplicates: inputs.length - inserted };
});
}
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
const limit = query.limit ?? 100;
return this.tx(query.tenant_id, async (tx) => {
const rows = await tx`
SELECT f.*, e.entity_type, e.identity_namespace, e.identity_value
FROM opt_canonical_facts f
JOIN opt_canonical_entities e ON e.entity_id = f.entity_id
WHERE f.tenant_id = ${query.tenant_id}
${query.entity_id ? tx`AND f.entity_id = ${query.entity_id}` : tx``}
${query.predicate ? tx`AND f.predicate = ${query.predicate}` : tx``}
${query.entity_type ? tx`AND e.entity_type = ${query.entity_type}` : tx``}
${query.identity_namespace ? tx`AND e.identity_namespace = ${query.identity_namespace}` : tx``}
${query.identity_value ? tx`AND e.identity_value = ${query.identity_value}` : tx``}
ORDER BY f.observed_at DESC
LIMIT ${limit}`;
return rows.map((r) => ({
fact_id: r.fact_id, tenant_id: r.tenant_id, entity_id: r.entity_id,
entity: { entity_type: r.entity_type, identity_namespace: r.identity_namespace, identity_value: r.identity_value },
predicate: r.predicate, value: r.value_json, value_type: r.value_type,
value_unit: r.value_unit ?? undefined, source_ref: r.source_ref,
source_record_id: r.source_record_id ?? undefined,
st0re_artifact_id: r.st0re_artifact_id ?? undefined,
aud0_evidence_ref: r.aud0_evidence_ref ?? undefined, run_ref: r.run_ref ?? undefined,
extractor_version: r.extractor_version, observed_at: String(r.observed_at),
valid_from: r.valid_from ? String(r.valid_from) : undefined,
valid_to: r.valid_to ? String(r.valid_to) : undefined,
confidence: r.confidence === null ? undefined : Number(r.confidence),
quality_status: r.quality_status, status: r.status, created_at: String(r.created_at),
})) as CanonicalFact[];
});
}
}
-4
View File
@@ -1,4 +0,0 @@
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
import { CanonicalFactService } from "./canonical-fact-service";
export const canonicalFactService = new CanonicalFactService();
-165
View File
@@ -1,165 +0,0 @@
/**
* src/services/canonical-fact-service.ts
* ======================================
* 0pt's canonical structured-observation write/read surface (sql/022).
*
* Ownership boundary: 0pt stores normalized OBSERVED facts (what a source
* said, when, from which artifact). L0RE owns epistemic propositions and
* projections and must consume these rows through this API. Nothing here
* overwrites an earlier observation: the unique key includes source_ref +
* observed_at, so conflicting sources are preserved as separate rows.
*
* Idempotency: identical re-writes hit ON CONFLICT DO NOTHING and are
* reported as `duplicates`, never duplicated.
*/
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
import { SiaxError } from "../platform/errors";
import {
createCanonicalFactRepository,
entityKey,
type CanonicalFactRepository,
} from "../repositories/canonical-fact-repository";
import type {
CanonicalEntity,
CanonicalEntityInput,
CanonicalFact,
CanonicalFactInput,
CanonicalQuery,
CanonicalRelationInput,
CanonicalWriteRequest,
CanonicalWriteResult,
FactValueType,
} from "../types/canonical-facts";
const FACT_VALUE_TYPES: FactValueType[] = ["string", "number", "boolean", "date", "json"];
function inferValueType(value: unknown): FactValueType {
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
if (typeof value === "boolean") return "boolean";
return "json";
}
function assertEntity(entity: CanonicalEntityInput, label: string): void {
if (!entity || !entity.entity_type || !entity.identity_namespace || !entity.identity_value) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `${label} requires entity_type, identity_namespace and identity_value`);
}
}
function assertFact(fact: CanonicalFactInput, index: number): void {
assertEntity(fact.entity, `facts[${index}].entity`);
if (!fact.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].predicate is required`);
if (!fact.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].source_ref is required`);
if (!fact.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].extractor_version is required`);
if (!fact.observed_at || Number.isNaN(Date.parse(fact.observed_at))) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].observed_at must be an ISO timestamp`);
}
if (fact.value === undefined) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value is required`);
if (fact.value_type && !FACT_VALUE_TYPES.includes(fact.value_type)) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value_type is invalid`);
}
if (fact.confidence !== undefined && (fact.confidence < 0 || fact.confidence > 1)) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].confidence must be between 0 and 1`);
}
}
function assertRelation(relation: CanonicalRelationInput, index: number): void {
assertEntity(relation.subject, `relations[${index}].subject`);
assertEntity(relation.object, `relations[${index}].object`);
if (!relation.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].predicate is required`);
if (!relation.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].source_ref is required`);
if (!relation.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].extractor_version is required`);
if (!relation.observed_at || Number.isNaN(Date.parse(relation.observed_at))) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].observed_at must be an ISO timestamp`);
}
}
export interface CanonicalFactServiceOptions {
repository?: CanonicalFactRepository;
aud0BaseUrl?: string;
databaseUrl?: string;
}
export class CanonicalFactService {
private readonly repo: CanonicalFactRepository;
private readonly aud0: Aud0Client;
constructor(opts?: CanonicalFactServiceOptions) {
this.repo = opts?.repository ?? createCanonicalFactRepository(opts?.databaseUrl);
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
}
async write(input: CanonicalWriteRequest): Promise<CanonicalWriteResult> {
if (!input.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
const facts = input.facts ?? [];
const relations = input.relations ?? [];
const explicit = input.entities ?? [];
if (facts.length === 0 && relations.length === 0 && explicit.length === 0) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "at least one entity, fact or relation is required");
}
facts.forEach(assertFact);
relations.forEach(assertRelation);
// Unique entity inputs from explicit + facts + relations.
const unique = new Map<string, CanonicalEntityInput>();
for (const e of [...explicit, ...facts.map((f) => f.entity), ...relations.flatMap((r) => [r.subject, r.object])]) {
assertEntity(e, "entity");
unique.set(entityKey(e), e);
}
const entities: CanonicalEntity[] = await this.repo.upsertEntities(input.tenant_id, [...unique.values()]);
const entityIds = new Map(entities.map((e) => [entityKey(e), e.entity_id]));
const normalizedFacts = facts.map((f) => ({ ...f, value_type: f.value_type ?? inferValueType(f.value) }));
const factResult = await this.repo.insertFacts(input.tenant_id, normalizedFacts, entityIds);
const relationResult = await this.repo.insertRelations(input.tenant_id, relations, entityIds);
// AUD0: counts and provenance refs only, never fact values (PII discipline).
try {
await this.aud0.ingestEvent({
event_id: `evt_0pt_canonical_facts_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
event_type: "0pt.canonical_facts_written",
tenant_id: input.tenant_id,
project_id: input.project_id ?? input.tenant_id,
actor_id: "0pt",
app_id: "0pt",
capability_id: "0pt.canonical_fact_write",
resource_type: "canonical_facts",
resource_id: input.tenant_id,
risk_level: "L2",
created_at: new Date().toISOString(),
payload: {
entities: entities.length,
facts_inserted: factResult.inserted,
facts_duplicates: factResult.duplicates,
relations_inserted: relationResult.inserted,
relations_duplicates: relationResult.duplicates,
source_refs: [...new Set([...facts.map((f) => f.source_ref), ...relations.map((r) => r.source_ref)])],
run_refs: [...new Set([...facts.map((f) => f.run_ref), ...relations.map((r) => r.run_ref)].filter(Boolean))],
},
});
} catch {
// AUD0 outage must not lose the canonical write; the caller sees counts.
}
return {
entities,
facts: normalizedFacts.map((f) => ({
fact_id: "",
entity_id: entityIds.get(entityKey(f.entity)) ?? "",
predicate: f.predicate,
})),
relations: relations.map((r) => ({
relation_id: "",
subject_id: entityIds.get(entityKey(r.subject)) ?? "",
predicate: r.predicate,
object_id: entityIds.get(entityKey(r.object)) ?? "",
})),
duplicates: factResult.duplicates + relationResult.duplicates,
};
}
async query(query: CanonicalQuery): Promise<CanonicalFact[]> {
if (!query.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
return this.repo.queryFacts({ ...query, limit: Math.min(query.limit ?? 100, 500) });
}
}
-102
View File
@@ -1,102 +0,0 @@
/**
* src/types/canonical-facts.ts — 0pt canonical structured observations.
*
* 0pt owns OBSERVED facts (source said X at time T, from artifact A). L0RE
* owns epistemic propositions/assertions and projections derived from these.
* This contract is the single write path into the canonical observation store;
* downstream products consume it through the same API instead of their own DB.
*/
export interface CanonicalEntityInput {
entity_type: string;
identity_namespace: string;
identity_value: string;
}
export interface CanonicalEntity extends CanonicalEntityInput {
entity_id: string;
tenant_id: string;
created_at: string;
updated_at: string;
}
export type FactValueType = "string" | "number" | "boolean" | "date" | "json";
export type QualityStatus = "passed" | "warned" | "failed" | "unvalidated";
export type FactStatus = "observed" | "supported" | "disputed" | "retracted";
export interface CanonicalFactInput {
entity: CanonicalEntityInput;
predicate: string;
value: unknown;
value_type?: FactValueType;
value_unit?: string;
source_ref: string;
source_record_id?: string;
st0re_artifact_id?: string;
aud0_evidence_ref?: string;
run_ref?: string;
extractor_version: string;
observed_at: string;
valid_from?: string;
valid_to?: string;
confidence?: number;
quality_status?: QualityStatus;
status?: FactStatus;
}
export interface CanonicalRelationInput {
subject: CanonicalEntityInput;
predicate: string;
object: CanonicalEntityInput;
attributes?: Record<string, unknown>;
source_ref: string;
st0re_artifact_id?: string;
aud0_evidence_ref?: string;
run_ref?: string;
extractor_version: string;
observed_at: string;
valid_from?: string;
valid_to?: string;
confidence?: number;
}
export interface CanonicalFact extends CanonicalFactInput {
fact_id: string;
tenant_id: string;
entity_id: string;
created_at: string;
}
export interface CanonicalRelation extends CanonicalRelationInput {
relation_id: string;
tenant_id: string;
subject_id: string;
object_id: string;
created_at: string;
}
export interface CanonicalWriteRequest {
tenant_id: string;
project_id?: string;
entities?: CanonicalEntityInput[];
facts?: CanonicalFactInput[];
relations?: CanonicalRelationInput[];
}
export interface CanonicalWriteResult {
entities: CanonicalEntity[];
facts: { fact_id: string; entity_id: string; predicate: string }[];
relations: { relation_id: string; subject_id: string; predicate: string; object_id: string }[];
/** Identical re-inserts skipped by the unique key (idempotent replay). */
duplicates: number;
}
export interface CanonicalQuery {
tenant_id: string;
entity_id?: string;
entity_type?: string;
identity_namespace?: string;
identity_value?: string;
predicate?: string;
limit?: number;
}
-60
View File
@@ -263,63 +263,3 @@ export const activateSourceSchema = z.object({
tenant_id: z.string().min(1),
actor_id: z.string().min(1).optional(),
});
/** POST /api/0pt/canonical/facts — canonical structured observation write (sql/022). */
const canonicalEntityInputSchema = z.object({
entity_type: z.string().min(1),
identity_namespace: z.string().min(1),
identity_value: z.string().min(1),
});
const canonicalValueSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(z.unknown()),
jsonObject,
]);
export const canonicalFactInputSchema = z.object({
entity: canonicalEntityInputSchema,
predicate: z.string().min(1),
value: canonicalValueSchema,
value_type: z.enum(['string', 'number', 'boolean', 'date', 'json']).optional(),
value_unit: z.string().min(1).optional(),
source_ref: z.string().min(1),
source_record_id: z.string().min(1).optional(),
st0re_artifact_id: z.string().min(1).optional(),
aud0_evidence_ref: z.string().min(1).optional(),
run_ref: z.string().min(1).optional(),
extractor_version: z.string().min(1),
observed_at: z.string().min(1),
valid_from: z.string().min(1).optional(),
valid_to: z.string().min(1).optional(),
confidence: z.number().min(0).max(1).optional(),
quality_status: z.enum(['passed', 'warned', 'failed', 'unvalidated']).optional(),
status: z.enum(['observed', 'supported', 'disputed', 'retracted']).optional(),
});
export const canonicalRelationInputSchema = z.object({
subject: canonicalEntityInputSchema,
predicate: z.string().min(1),
object: canonicalEntityInputSchema,
attributes: jsonObject.optional(),
source_ref: z.string().min(1),
st0re_artifact_id: z.string().min(1).optional(),
aud0_evidence_ref: z.string().min(1).optional(),
run_ref: z.string().min(1).optional(),
extractor_version: z.string().min(1),
observed_at: z.string().min(1),
valid_from: z.string().min(1).optional(),
valid_to: z.string().min(1).optional(),
confidence: z.number().min(0).max(1).optional(),
});
export const canonicalWriteSchema = z.object({
tenant_id: z.string().min(1),
project_id: z.string().min(1).optional(),
entities: z.array(canonicalEntityInputSchema).optional(),
facts: z.array(canonicalFactInputSchema).optional(),
relations: z.array(canonicalRelationInputSchema).optional(),
});
-86
View File
@@ -1,86 +0,0 @@
/**
* Canonical structured observations (sql/022): idempotent write, conflict
* preservation, tenant isolation and relation identity resolution.
*/
import { describe, it, expect } from "vitest";
import { CanonicalFactService } from "@/src/services/canonical-fact-service";
import { SiaxError } from "@/src/platform/errors";
const T = "t_canonical_test";
const ENTITY = { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org" };
function fact(overrides: Record<string, unknown> = {}) {
return {
entity: ENTITY,
predicate: "web.title",
value: "Example Domain",
source_ref: "source:example",
extractor_version: "0pt.motor.extraction/v1",
observed_at: "2026-11-04T12:00:00Z",
...overrides,
};
}
describe("CanonicalFactService — write", () => {
it("creates the entity and the fact, and query returns it", async () => {
const svc = new CanonicalFactService();
const result = await svc.write({ tenant_id: T, facts: [fact()] });
expect(result.entities).toHaveLength(1);
expect(result.entities[0].identity_value).toBe("https://example.org");
expect(result.facts).toHaveLength(1);
expect(result.duplicates).toBe(0);
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
expect(rows).toHaveLength(1);
expect(rows[0].predicate).toBe("web.title");
expect(rows[0].value_type).toBe("string");
});
it("is idempotent: an identical replay is reported as a duplicate, not stored twice", async () => {
const svc = new CanonicalFactService();
await svc.write({ tenant_id: T, facts: [fact()] });
const replay = await svc.write({ tenant_id: T, facts: [fact()] });
expect(replay.duplicates).toBe(1);
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
expect(rows).toHaveLength(1);
});
it("preserves conflicting sources as separate observations", async () => {
const svc = new CanonicalFactService();
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:a", value: "A" })] });
await svc.write({ tenant_id: T, facts: [fact({ source_ref: "source:b", value: "B" })] });
const rows = await svc.query({ tenant_id: T, identity_value: "https://example.org" });
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.value).sort()).toEqual(["A", "B"]);
});
it("isolates tenants", async () => {
const svc = new CanonicalFactService();
await svc.write({ tenant_id: "t_a", facts: [fact()] });
expect(await svc.query({ tenant_id: "t_b" })).toHaveLength(0);
});
it("resolves relation subject/object identities to entity ids", async () => {
const svc = new CanonicalFactService();
const result = await svc.write({
tenant_id: T,
relations: [{
subject: ENTITY,
predicate: "LINKS_TO",
object: { entity_type: "web_page", identity_namespace: "url", identity_value: "https://example.org/next" },
source_ref: "source:example",
extractor_version: "0pt.motor.extraction/v1",
observed_at: "2026-11-04T12:00:00Z",
}],
});
expect(result.entities).toHaveLength(2);
expect(result.relations[0].subject_id).toBeTruthy();
expect(result.relations[0].object_id).toBeTruthy();
expect(result.relations[0].subject_id).not.toBe(result.relations[0].object_id);
});
it("rejects a fact without observed_at or with an invalid confidence", async () => {
const svc = new CanonicalFactService();
await expect(svc.write({ tenant_id: T, facts: [fact({ observed_at: undefined })] })).rejects.toBeInstanceOf(SiaxError);
await expect(svc.write({ tenant_id: T, facts: [fact({ confidence: 2 })] })).rejects.toBeInstanceOf(SiaxError);
});
});