Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46ebbf810 | ||
|
|
03cdb7815e | ||
|
|
116304849b | ||
|
|
c7966eb586 | ||
|
|
d5aea59482 | ||
|
|
ce6be94e50 | ||
|
|
4c20e5457f | ||
|
|
b29e4848b6 | ||
|
|
f53bcf8f50 | ||
|
|
76e3c0dc2d | ||
|
|
bcdfc536b7 | ||
|
|
77ddc0654d | ||
|
|
063206d8b0 | ||
|
|
024f4fa8c3 | ||
|
|
19df2b6a20 | ||
|
|
13e156ec7e | ||
|
|
57616d84b7 | ||
|
|
b547046963 |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.acquisition-consumer.schema.json",
|
||||
"schemaVersion": "siax.acquisition-consumer/v1",
|
||||
"application": "0pt",
|
||||
"owner": "0pt",
|
||||
"tenantScoped": true,
|
||||
"capabilities": [
|
||||
"siax.acquisition.plan",
|
||||
"siax.acquisition.run",
|
||||
"siax.acquisition.status",
|
||||
"siax.acquisition.result",
|
||||
"siax.acquisition.query_existing",
|
||||
"siax.acquisition.discovery_search",
|
||||
"siax.acquisition.extract",
|
||||
"siax.acquisition.evidence"
|
||||
],
|
||||
"allowedPurposes": [
|
||||
"data.acquisition",
|
||||
"source.registration"
|
||||
],
|
||||
"dataCategories": [
|
||||
"public",
|
||||
"company",
|
||||
"product",
|
||||
"pricing",
|
||||
"vehicle",
|
||||
"property",
|
||||
"document",
|
||||
"event",
|
||||
"other"
|
||||
],
|
||||
"maxCostSEK": 25,
|
||||
"reuseExistingFacts": true,
|
||||
"mustNotOwnEngines": true,
|
||||
"engines": []
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
* Customer calls use verified Zitadel bearer identity and receive immutable
|
||||
* x-0pt-verified-* headers. Explicit machine routes may instead use the
|
||||
* shared X-Internal-Token convention; mutation handlers verify it again for
|
||||
* defence in depth.
|
||||
* defence in depth. The canonical-fact write is one such explicit machine route.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
||||
@@ -26,6 +26,9 @@ const EXEMPT_PATTERNS: RegExp[] = [
|
||||
/** Routes that may be reached with a service token instead of end-user bearer auth. */
|
||||
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
||||
/^\/api\/0pt\/quality\/bridge-run$/,
|
||||
// Acquisition engine -> 0pt canonical observation write (machine-to-machine,
|
||||
// same X-Internal-Token class as the quality bridge; not a blanket bypass).
|
||||
/^\/api\/0pt\/canonical\/facts$/,
|
||||
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
|
||||
/^\/api\/0pt\/internal\/er\/candidates$/,
|
||||
/^\/api\/0pt\/search\/definitions$/,
|
||||
|
||||
+5
-4
@@ -52,13 +52,15 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.19",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@siax/0pt-decision": "workspace:*",
|
||||
"@siax/access": "workspace:*",
|
||||
"@siax/auth-zitadel": "0.1.1",
|
||||
"@siax/charts": "^0.5.0",
|
||||
"@siax/collaboration": "0.3.0",
|
||||
"@siax/config": "^0.2.1",
|
||||
"@siax/contracts": "^0.11.1",
|
||||
"@siax/contracts": "^0.12.1",
|
||||
"@siax/import-export": "^0.2.2",
|
||||
"@siax/tokens": "^0.1.2",
|
||||
"@siax/ui": "^0.15.1",
|
||||
"@siax/ui": "^0.16.13",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -81,8 +83,7 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.76",
|
||||
"@siax/access": "workspace:*"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
|
||||
Generated
+56
-22
@@ -108,6 +108,9 @@ importers:
|
||||
'@siax/access':
|
||||
specifier: workspace:*
|
||||
version: link:packages/siax-access
|
||||
'@siax/auth-zitadel':
|
||||
specifier: 0.1.1
|
||||
version: 0.1.1
|
||||
'@siax/charts':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -118,8 +121,8 @@ importers:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts':
|
||||
specifier: ^0.11.1
|
||||
version: 0.11.1
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.1
|
||||
'@siax/import-export':
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
@@ -127,8 +130,8 @@ importers:
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@siax/ui':
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
specifier: ^0.16.13
|
||||
version: 0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.5(postcss@8.5.28)
|
||||
@@ -1546,14 +1549,23 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@siax/charts@0.5.0':
|
||||
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
|
||||
'@siax/analytical-context@0.5.1':
|
||||
resolution: {integrity: sha512-bqc2NGPyzLVcu4VUgz1XYsyghSM4c3ck5gBVFSzeRYMhe2cj0nf5Fm5n/sFaOuhMaKdDnX/TIR2FO6f21lgufg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fanalytical-context/-/0.5.1/analytical-context-0.5.1.tgz}
|
||||
|
||||
'@siax/auth-core@0.1.0':
|
||||
resolution: {integrity: sha512-oTfEO4S6pv3rQ6bSh44+fRWPiwGr9nYCf6uWSBF8g5PFUspW6nr4wKQZVAkLNwfC0Pu6o+CH8ltsuu+fY8gOQA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-core/-/0.1.0/auth-core-0.1.0.tgz}
|
||||
|
||||
'@siax/auth-zitadel@0.1.1':
|
||||
resolution: {integrity: sha512-SmoBswG5P7szZIaETqmo1NN5RhdXLq2RDR/1wObfxHM2yUEXNSZQJznZoAb8z3lm/PvBgQhmnHiOHCfidN1ZQg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-zitadel/-/0.1.1/auth-zitadel-0.1.1.tgz}
|
||||
|
||||
'@siax/charts@0.11.0':
|
||||
resolution: {integrity: sha512-IQnby+h6jVMaRAM7DcfUMuMbSUjd8B3+AGk9N4pXk2GDudMRMjt0bTtu75637Jc1Mf4iNonvgp8ytKlS7KdW4A==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.11.0/charts-0.11.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@siax/charts@0.5.1':
|
||||
resolution: {integrity: sha512-SFo6771jplE3fpUu2IsK11NipM5iAlM2h1N7jdVgsqqs3do+TnOi2knhIPSpUBhKfh5bZwMvO5v2TsrZmtsO+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.1/charts-0.5.1.tgz}
|
||||
'@siax/charts@0.5.0':
|
||||
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
@@ -1569,14 +1581,20 @@ packages:
|
||||
'@siax/contracts@0.11.1':
|
||||
resolution: {integrity: sha512-sn0ZlprJE8LcbKF4PF8wBOhdOfygOG/JbU7nB+NDFBBP/AhtvMXcxFGl1oX0GadifD7VqZrVSoCliQqY/V/HYg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.11.1/contracts-0.11.1.tgz}
|
||||
|
||||
'@siax/contracts@0.12.1':
|
||||
resolution: {integrity: sha512-gSvMYRSxfbPPAqUGRjUMO1s38E4LdmIzDEpiNNIiGVminDHHwCOCybnhf2YNYaIfm6VBYoq4rP4yCERrpJregA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.12.1/contracts-0.12.1.tgz}
|
||||
|
||||
'@siax/import-export@0.2.2':
|
||||
resolution: {integrity: sha512-mb2fM6lVjWh6cVdoBFOLTdyjYRtjMjlSxNtpNHZHdW51tDrNZRNZc0Mz+lVIFsJHQ79NbL2J8GsWpHmL/rVBzg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.2/import-export-0.2.2.tgz}
|
||||
|
||||
'@siax/import-export@0.2.4':
|
||||
resolution: {integrity: sha512-xYRLF/5B7EyK3jbZCsPvDcEhZRGC/72QQpkEIsFJQqKwUxsejiQ1RabjzLoF0wNjIKlubEYnkfxDBaJMeIgKrg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.4/import-export-0.2.4.tgz}
|
||||
|
||||
'@siax/tokens@0.1.2':
|
||||
resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz}
|
||||
|
||||
'@siax/ui@0.15.1':
|
||||
resolution: {integrity: sha512-GeU+Iwj7s3cWJUHfvt0r07dWGCGvh9jG9cS9D4EkdKb+EQGhOns2IGbDNPYRkLj/t55Qz2fjSrYCMwXO19zPDw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.15.1/ui-0.15.1.tgz}
|
||||
'@siax/ui@0.16.13':
|
||||
resolution: {integrity: sha512-qWmGexMa7kqol5O3MBDkraF/s5APAahNyGHQusDZ34YDZvGnlr3t7raWIVJMFHgz6D0Fsgyuak6210Ktn3SpHA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.16.13/ui-0.16.13.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
@@ -4225,16 +4243,17 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
echarts: 6.1.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tailwind-merge: 2.6.1
|
||||
'@siax/analytical-context@0.5.1': {}
|
||||
|
||||
'@siax/charts@0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
'@siax/auth-core@0.1.0': {}
|
||||
|
||||
'@siax/auth-zitadel@0.1.1':
|
||||
dependencies:
|
||||
'@siax/auth-core': 0.1.0
|
||||
|
||||
'@siax/charts@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
dependencies:
|
||||
'@siax/analytical-context': 0.5.1
|
||||
'@siax/config': 0.2.1(tailwindcss@4.3.3)
|
||||
clsx: 2.1.1
|
||||
echarts: 6.1.0
|
||||
@@ -4244,6 +4263,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
echarts: 6.1.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tailwind-merge: 2.6.1
|
||||
|
||||
'@siax/collaboration@0.3.0': {}
|
||||
|
||||
'@siax/config@0.2.1(tailwindcss@4.3.3)':
|
||||
@@ -4252,13 +4279,19 @@ snapshots:
|
||||
|
||||
'@siax/contracts@0.11.1': {}
|
||||
|
||||
'@siax/contracts@0.12.1': {}
|
||||
|
||||
'@siax/import-export@0.2.2':
|
||||
dependencies:
|
||||
'@siax/contracts': 0.11.1
|
||||
|
||||
'@siax/import-export@0.2.4':
|
||||
dependencies:
|
||||
'@siax/contracts': 0.12.1
|
||||
|
||||
'@siax/tokens@0.1.2': {}
|
||||
|
||||
'@siax/ui@0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
'@siax/ui@0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4281,10 +4314,11 @@ snapshots:
|
||||
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@siax/charts': 0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
'@siax/analytical-context': 0.5.1
|
||||
'@siax/charts': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
'@siax/config': 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts': 0.11.1
|
||||
'@siax/import-export': 0.2.2
|
||||
'@siax/contracts': 0.12.1
|
||||
'@siax/import-export': 0.2.4
|
||||
'@siax/tokens': 0.1.2
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"repo": "0pt",
|
||||
"role": "consumer",
|
||||
"canonicalOwner": "sax3l/DES1GN",
|
||||
"sourceRoots": [
|
||||
"app",
|
||||
"components"
|
||||
],
|
||||
"packagePolicy": {
|
||||
"namespace": "@siax",
|
||||
"registry": "https://git.siax.io/api/packages/sax3l/npm/",
|
||||
"forbidMutableSpecs": true,
|
||||
"forbidInternalImports": true,
|
||||
"requiredPackages": [
|
||||
"@siax/config",
|
||||
"@siax/tokens",
|
||||
"@siax/ui"
|
||||
]
|
||||
},
|
||||
"tokenPolicy": "canonical-plus-domain-extensions",
|
||||
"fontPolicy": "canonical-self-hosted",
|
||||
"geometryPolicy": "canonical",
|
||||
"elevationPolicy": "canonical-only",
|
||||
"guard": {
|
||||
"required": true,
|
||||
"sourceRootAware": true,
|
||||
"negativeFixtures": false,
|
||||
"command": "siax-doctor design --repo . --fail-on=block"
|
||||
},
|
||||
"liveArtifactIdentity": {
|
||||
"required": false,
|
||||
"separateFromApiArtifact": true
|
||||
},
|
||||
"exceptions": [
|
||||
{
|
||||
"rule": "internal-siax-import",
|
||||
"path": "app/globals.css",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/accordion.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/alert.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/aspect-ratio.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/avatar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/badge.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/breadcrumb.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/carousel.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/checkbox.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/collapsible.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/command.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/drawer.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/field.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/form.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input-otp.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/item.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/kbd.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/label.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/pagination.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/progress.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/radio-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/resizable.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/scroll-area.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/separator.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sheet.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/skeleton.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/slider.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/sonner.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/spinner.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/switch.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/table.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toaster.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "local-primitive",
|
||||
"path": "components/ui/tooltip.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/checkbox.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input-otp.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/radio-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/sheet.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/slider.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/switch.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-elevation",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/accordion.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/alert-dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/alert.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/badge.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/button-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/button.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/calendar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/command.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/context-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/dialog.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/dropdown-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/empty.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/field.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/hover-card.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/input-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/input.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/item.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/kbd.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/menubar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/navigation-menu.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/popover.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/select.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/sidebar.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/skeleton.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/tabs.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/textarea.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toast.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toggle-group.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/toggle.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
},
|
||||
{
|
||||
"rule": "raw-radius",
|
||||
"path": "components/ui/tooltip.tsx",
|
||||
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
|
||||
"owner": "0pt",
|
||||
"expires": "2027-03-31"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "pe-artifact-0pt-13e156ec7e6b",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "artifact-digest",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-11-04T10:53:16Z",
|
||||
"verifiedBy": "docker image inspect RepoDigests (real registry digest)",
|
||||
"proofRef": "docker://server6 image shxd6lepxuxfsjsp0ath3z1g:13e156ec7e6be534de9c28d28ec2daff0355ca7f image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
|
||||
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
|
||||
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
|
||||
"tool": "docker"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "pe-ci-0pt-13e156ec7e6b",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "deploy-log",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-11-04T10:53:16Z",
|
||||
"verifiedBy": "Coolify deployment record (application_deployment_queues, status=finished)",
|
||||
"proofRef": "coolify://server6/shxd6lepxuxfsjsp0ath3z1g finished 2026-11-04 05:42:18",
|
||||
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "pe-running-0pt-13e156ec7e6b",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "running-digest",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-11-04T10:53:16Z",
|
||||
"verifiedBy": "docker inspect (running container image digest)",
|
||||
"proofRef": "docker://server6 container/shxd6lepxuxfsjsp0ath3z1g-20261104T093337 image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
|
||||
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
|
||||
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "pe-sbom-0pt-13e156ec7e6b",
|
||||
"repo": "0pt",
|
||||
"evidenceType": "sbom",
|
||||
"result": "pass",
|
||||
"verifiedAt": "2026-11-04T09:29:06Z",
|
||||
"verifiedBy": "syft (estate-kampanj issue #76, skannad vid deployad commit)",
|
||||
"proofRef": "siax/sbom/sbom-0pt-13e156ec7e6b.json",
|
||||
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
|
||||
"tool": "syft",
|
||||
"sbomFormat": "cyclonedx-json"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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",
|
||||
"artifactId": "shxd6lepxuxfsjsp0ath3z1g@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
-- 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));
|
||||
+25
-8
@@ -50,6 +50,7 @@ import {
|
||||
type JWTPayload,
|
||||
type JWTVerifyGetKey,
|
||||
} from 'jose';
|
||||
import { normalizeZitadelClaims, ZitadelClaimError } from '@siax/auth-zitadel';
|
||||
|
||||
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
|
||||
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
|
||||
@@ -254,13 +255,29 @@ export async function verifyBearer(
|
||||
throw new BearerVerificationError('token_verification_failed', err);
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
|
||||
if (!sub || sub.length === 0) {
|
||||
throw new BearerVerificationError('token_missing_sub');
|
||||
try {
|
||||
const identity = normalizeZitadelClaims(payload, {
|
||||
source: 'jwt',
|
||||
tenantClaim: opts.tenantClaim ?? tenantClaimOverride(),
|
||||
});
|
||||
return {
|
||||
sub: identity.principal.subject,
|
||||
tenantId: identity.tenant.tenantId,
|
||||
claims: payload,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ZitadelClaimError) {
|
||||
if (/sub/i.test(err.reason)) {
|
||||
throw new BearerVerificationError('token_missing_sub');
|
||||
}
|
||||
if (/tenant/i.test(err.reason)) {
|
||||
throw new BearerVerificationError('token_missing_tenant_claim');
|
||||
}
|
||||
if (/aud/i.test(err.reason)) {
|
||||
throw new BearerVerificationError('token_invalid_audience');
|
||||
}
|
||||
throw new BearerVerificationError('token_verification_failed', err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const tenantId = deriveTenantId(payload, opts.tenantClaim ?? tenantClaimOverride());
|
||||
if (!tenantId) {
|
||||
throw new BearerVerificationError('token_missing_tenant_claim');
|
||||
}
|
||||
return { sub, tenantId, claims: payload };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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[];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
|
||||
import { CanonicalFactService } from "./canonical-fact-service";
|
||||
|
||||
export const canonicalFactService = new CanonicalFactService();
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -263,3 +263,63 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -45,6 +45,13 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('a valid X-Internal-Token lets the acquisition engine write canonical facts', async () => {
|
||||
const res = await middleware(
|
||||
req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('an invalid X-Internal-Token still falls through to the ordinary Bearer requirement (401, not a bypass)', async () => {
|
||||
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
@@ -40,6 +40,7 @@ async function sign(claims: Record<string, unknown>, opts: SignOpts = {}): Promi
|
||||
.setProtectedHeader({ alg: 'RS256', kid: KID })
|
||||
.setIssuedAt()
|
||||
.setIssuer(opts.issuer ?? ISSUER)
|
||||
.setAudience('siax-test')
|
||||
.setExpirationTime(opts.expirationTime ?? '2h')
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user