docs(production): standardiserad produktionsberedskap och gap-analys

This commit was merged in pull request #537.
This commit is contained in:
2026-11-10 02:07:52 +00:00
959 changed files with 5 additions and 115771 deletions
-12
View File
@@ -1,12 +0,0 @@
# BACKLOG P6 (CI-16): lets a developer run `act` to execute .gitea/workflows
# (GitHub-Actions-compatible syntax) locally before pushing, instead of
# discovering a workflow bug only after a Gitea runner picks it up.
# https://github.com/nektos/act
#
# ubuntu-latest here maps to a real Docker image (act's default is a minimal
# image missing tools this repo's workflows expect, e.g. curl for the
# Trivy/gitleaks install steps) -- catthehacker's full image matches what
# GitHub-hosted (and this fleet's siax-runner-*) ubuntu-latest actually has.
-P ubuntu-latest=catthehacker/ubuntu:full-latest
-W .gitea/workflows
--container-architecture linux/amd64
-50
View File
@@ -1,50 +0,0 @@
/**
* BACKLOG P6 (ARK-14): import-boundary check. Advisory today (run manually /
* `pnpm run deps:check`) -- not wired as a blocking CI gate in this pass,
* so a first real run surfacing findings doesn't turn a green PR red
* unexpectedly. Wiring it into CI is a reasonable next step once the
* initial finding set (if any) has been triaged.
*
* Boundaries encoded, matching CLAUDE.md / docs/architecture/ARCHITECTURE.md:
* - `services/scrapers` and `services/api` are Python -- not traversed by
* this JS/TS dependency graph at all; no rule needed, they're simply
* out of dependency-cruiser's file-type scope.
* - `src/` (the capability layer, blocking-typechecked) must not import
* from `app/` or `components/` (the v0-generated UI layer, advisory-only)
* -- that would pull UI-layer assumptions into the tested backend layer.
* - Nothing may import from `tests/` (test helpers stay test-only).
*/
module.exports = {
forbidden: [
{
name: 'no-src-importing-app-or-components',
comment:
'src/ is the blocking-typechecked capability layer (tsconfig.check.json); app/ and components/ are the advisory v0-generated UI layer. src/ importing from them would pull unverified UI-layer code into the tested backend layer.',
severity: 'error',
from: { path: '^src' },
to: { path: '^(app|components)' },
},
{
name: 'no-importing-tests',
comment: 'Test helpers stay test-only -- application code must not depend on tests/.',
severity: 'error',
from: { pathNot: '^tests' },
to: { path: '^tests' },
},
{
name: 'no-circular',
comment: 'Circular imports make the module graph hard to reason about and can hide load-order bugs.',
severity: 'warn',
from: {},
to: { circular: true },
},
],
options: {
tsPreCompilationDeps: true,
tsConfig: { fileName: 'tsconfig.json' },
exclude: { path: 'node_modules|\\.next|services/(api|scrapers)|services/infra' },
reporterOptions: {
text: { highlightFocused: true },
},
},
};
-15
View File
@@ -1,15 +0,0 @@
{
"name": "0pt",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"postCreateCommand": "corepack enable && corepack prepare pnpm@9 --activate && pnpm install --frozen-lockfile",
"postStartCommand": "node scripts/siax-doctor.mjs || true",
"forwardPorts": [3000],
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
}
},
"remoteEnv": {
"AUTH_ENFORCE": "false"
}
}
-35
View File
@@ -1,35 +0,0 @@
# Keep the build context small + reproducible. Do NOT exclude app source dirs
# (app/, components/, hooks/, lib/, packages/, src/, styles/, public/) -- next
# build needs them.
node_modules
.next
out
build
.git
.gitea
.github
# local env / secrets -- never ship into an image
.env
.env.*
!.env.example
.vercel
*.tsbuildinfo
next-env.d.ts
# python services + their large sample dumps are not part of the Next build
**/__pycache__
*.pyc
services/scrapers/output
# editor / OS noise
.DS_Store
*.log
npm-debug.log*
.pnpm-debug.log*
# the Docker files themselves
Dockerfile
.dockerignore
-25
View File
@@ -1,25 +0,0 @@
# BACKLOG P6 (REPO-13). Matches .prettierrc.json's 2-space indent -- this is
# a floor for editors that don't run Prettier on save, not a replacement for
# it (quote style/semicolons/line width stay Prettier's job, not
# EditorConfig's). Note: `pnpm run format` currently reports ~300
# pre-existing style violations across this repo (verified 2026-08-11,
# out of this pass's scope) -- this file governs new edits, not a
# retroactive reformat.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.py]
indent_size = 4
[Makefile]
indent_style = tab
-301
View File
@@ -1,301 +0,0 @@
# =============================================================================
# 0pt (edge plane) -- environment inventory / example environment
# =============================================================================
# Copy to `.env` (gitignored) and fill in real values. Never commit real values.
# Vercel env for the edge app; VPS .env (git-ignored) for infra/docker-compose.yml.
# See docs/08-infrastructure.md for the canonical description of each integration.
#
# Two provenance markers are used below and they mean different things:
# * "wired in code today" -- read via a `process.env.*` somewhere in
# this repo right now (grep-verified).
# * "documented target, not yet read" -- named in docs/08-infrastructure.md /
# docs/02-architecture.md but with no
# `process.env.*` read yet. Filling it in
# unblocks the integration; it does not by
# itself change behaviour.
#
# The app is designed to build and boot with all of these UNSET: lib/db.ts and
# lib/stripe.ts are lazy + env-guarded, and the storage/payment/doc routes return
# HTTP 501 ("not configured") until the relevant keys are provided.
# ---------------------------------------------------------------------------
# Core / public
# ---------------------------------------------------------------------------
# Public base URL of this app (used for absolute links / callbacks).
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Documented target (docs/08); no call site found reading it in this pass.
# NOTE: api.0pt.app is a dead hostname (falls through to wildcard Vercel ->
# DEPLOYMENT_NOT_FOUND). The app serves its own /api routes on the site
# origin, so the honest base URL is the live origin itself.
NEXT_PUBLIC_API_URL=https://0pt.app
# ---------------------------------------------------------------------------
# Database (Postgres) -- wired in code today: lib/db.ts, app/api/health
# ---------------------------------------------------------------------------
# Vercel Postgres sets POSTGRES_URL / POSTGRES_PRISMA_URL automatically when the
# integration is attached. First one set wins, in this order (lib/db.ts):
# DATABASE_URL -> POSTGRES_URL -> POSTGRES_PRISMA_URL.
DATABASE_URL=postgres://user:password@localhost:5432/opt
POSTGRES_URL=
POSTGRES_PRISMA_URL=
# ---------------------------------------------------------------------------
# Cache / queue (Redis) -- app/api/health only checks presence; no other call
# site found reading it in this pass (worker queue usage, if any, lives in
# services/api, outside this repo's scope).
# ---------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379
# ---------------------------------------------------------------------------
# Auth -- session / dev-login / service-token secrets (wired in code today)
# ---------------------------------------------------------------------------
# Session-signing secret (generate a long random string).
AUTH_SECRET=change-me-long-random-string
# AUTH_URL is documented (docs/08) but not yet read by any route in this pass.
AUTH_URL=https://0pt.app
# Optional dev-only login shortcut key (leave UNSET in production).
DEV_LOGIN_KEY=
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
# on every engineFetch() call (lib/engine.ts).
INTERNAL_TOKEN=change-me-internal-token
# Tenant authority for the internal-token principal. Comma-separated allowlist
# of tenants this deployment's engine is entitled to write for, e.g.
# "379976258701823235". This is the ONLY source of tenant authority for
# X-Internal-Token: a request header may never name a tenant. Empty/unset means
# the service principal has no tenant authority and tenant-scoped writes
# (canonical facts, quality bridge runs) are denied with 403. Exactly ONE
# tenant is supported: the shared token identifies one service process, not a
# set of principals. A multi-tenant list is refused (401) until a verified
# workload-identity binding exists.
INTERNAL_SERVICE_TENANTS=
# ---------------------------------------------------------------------------
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
# ---------------------------------------------------------------------------
# 0pt verifies inbound Bearer tokens against the fleet Zitadel IdP
# (src/auth/zitadel.ts, wired via middleware.ts). JWKS are discovered from
# <issuer>/.well-known/openid-configuration, falling back to <issuer>/oauth/v2/keys.
#
# Fleet Zitadel issuer (OIDC). Default when unset: https://id-customers.siax.io
ZITADEL_ISSUER=https://id-customers.siax.io
# Browser login (Authorization Code + PKCE). Create a Web application in the
# Zitadel project with redirect URI https://0pt.app/api/auth/callback.
ZITADEL_OIDC_CLIENT_ID=
ZITADEL_OIDC_CLIENT_SECRET=
# Enforcement is FAIL-CLOSED. Leave this UNSET (or set it to "true") and a
# valid, verified Bearer token is REQUIRED on /api/0pt/* -- 401 on
# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant.
# ONLY the literal value "false" disables authentication, and that value is
# REFUSED when NODE_ENV=production: the app throws at startup instead of
# serving openly. Use "false" for local development only.
AUTH_ENFORCE=true
# Optional override for the claim key that carries the SIAX tenant id. When
# unset, tenant derivation uses the documented precedence:
# urn:zitadel:iam:org:id -> urn:zitadel:iam:user:resourceowner:id -> tenant_id -> tenant.
ZITADEL_TENANT_CLAIM=
# ---------------------------------------------------------------------------
# Data engine bridge -- wired in code today: lib/engine.ts
# ---------------------------------------------------------------------------
# When set, edge route handlers defer to the FastAPI engine (services/api) as the
# single source of truth (orders/estimates/catalog/ai-parse). When unset, the edge
# computes locally / falls back to an honest heuristic -- see lib/engine.ts and
# app/api/ai/parse.
ENGINE_URL=http://localhost:8000
# ---------------------------------------------------------------------------
# SIAX platform clients
# ---------------------------------------------------------------------------
# WIRED FOR REAL since 2026-08-31: every factory in src/clients/* reads its
# *_URL from process.env when no constructor baseUrl is given. Under
# NODE_ENV=production an UNSET url is a hard boot failure
# (SiaxError DEPENDENCY_UNAVAILABLE) — production never silently falls back
# to the in-memory mocks (Master-DoD §3/§10). In dev/test, unset = documented
# local mock/no-op behaviour.
CL0UD_URL=
N0D_URL=
AUD0_URL=
# Optional service bearer tokens for the platform clients (machine identity).
# Values come from Infisical/secret refs — NEVER commit real values here.
CL0UD_SERVICE_TOKEN=
N0D_SERVICE_TOKEN=
AUD0_SERVICE_TOKEN=
ST0RE_SERVICE_TOKEN=
# INF0 (AI OS) -- documented target, not yet read by any code in this repo.
# Route AI-parsning / model calls through the gateway instead of raw provider keys.
INF0_URL=
# ST0RE_URL is the convention name for src/clients/st0re-client.ts /
# st0re-lakehouse-client.ts and is read by the real-infra test suite
# (tests/p0/*.realinfra.test.ts). The lakehouse write path was live-verified
# against ST0RE on 2026-07-13 with this set; unset, the clients fall back to
# their deterministic in-memory mocks.
ST0RE_URL=
# ---------------------------------------------------------------------------
# P0 Data-OS backends (src/search, src/analytics, src/metadata, src/features)
# ---------------------------------------------------------------------------
# OpenSearch — search projections ONLY, never canonical truth (§42). Unset in
# dev/test = search capability unavailable (typed error), never a fake.
OPENSEARCH_URL=
# ClickHouse — 0pt capability analytics (tenant-isolated metric events).
# HAS NO CONNECTION to b0ts pipelines or quarantined market data (legal).
CLICKHOUSE_URL=
# "user:password" for ClickHouse basic auth; from secret refs only.
CLICKHOUSE_AUTH=
# OpenMetadata — metadata PROJECTION target (canonical stays in 0pt).
OPENMETADATA_URL=
OPENMETADATA_TOKEN=
# Feast feature server (online features path).
FEAST_SERVER_URL=
# ---------------------------------------------------------------------------
# Payments (Stripe) -- wired in code today: app/api/payments/*,
# app/api/webhooks/stripe
# ---------------------------------------------------------------------------
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Documented target (docs/08); not yet read by any route in this pass.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# ---------------------------------------------------------------------------
# Object storage -- Cloudflare R2 / S3-compatible
# ---------------------------------------------------------------------------
# R2_ENDPOINT / R2_BUCKET / R2_ACCESS_KEY_ID are read in code today; all three
# are required to enable /api/uploads, otherwise it returns 501.
# R2_ACCOUNT_ID and R2_SECRET_ACCESS_KEY are documented in docs/08 but were NOT
# found via a process.env.* read in this pass -- verify before assuming the R2
# client is fully wired.
R2_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
R2_BUCKET=opt-uploads
R2_ACCESS_KEY_ID=change-me
R2_ACCOUNT_ID=
R2_SECRET_ACCESS_KEY=
# Fallback local artifact store used when no object storage is configured
# (src/adapters/local-blob-store.ts). Leave unset in production.
OPT_LOCAL_BLOB_DIR=./.opt-blobs
# ---------------------------------------------------------------------------
# Document intelligence / OCR (Azure) -- wired in code today:
# src/services/document-extraction-service.ts. Unset => extraction
# "not configured".
# ---------------------------------------------------------------------------
DOC_INTELLIGENCE_ENDPOINT=https://<resource>.cognitiveservices.azure.com
DOC_INTELLIGENCE_KEY=change-me
DOC_INTELLIGENCE_API_VERSION=2024-11-30
# ---------------------------------------------------------------------------
# Customer DB-grant encryption -- wired in code today:
# /api/connections/db-grant
# ---------------------------------------------------------------------------
# 32-byte key, AES-GCM. Encrypts customer-supplied read-only connection strings
# at rest.
GRANT_ENC_KEY=change-me-32-byte-key
# ---------------------------------------------------------------------------
# External write-back targets
# ---------------------------------------------------------------------------
# Odoo base URL for the bills/write-back adapter.
ODOO_BASE_URL=https://<your-odoo-host>
# ---------------------------------------------------------------------------
# Email -- Resend (documented target, docs/08; no Resend call site found in
# this pass -- treat as not-yet-implemented, not just unconfigured)
# ---------------------------------------------------------------------------
RESEND_API_KEY=
# ---------------------------------------------------------------------------
# Embedded analytics (documented target, docs/08; no call site found in this
# pass)
# ---------------------------------------------------------------------------
METABASE_SITE_URL=
METABASE_SECRET_KEY=
NOCODB_BASE_URL=
NOCODB_API_TOKEN=
# ---------------------------------------------------------------------------
# Test-only (tests/p0 real-infra suite; not needed to run the app)
# ---------------------------------------------------------------------------
# ST0RE_TEST_TENANT_ID=
# ---------------------------------------------------------------------------
# Document workbench (upload -> DOC0 -> INF0 -> validated export -> credits)
# Wired in code today: app/api/0pt/documents/*, app/api/0pt/credits/*.
# Every value is optional to boot and mandatory to USE: a missing integration
# makes its route answer 503 "not configured" instead of fabricating success.
# ---------------------------------------------------------------------------
# DOC0 document service. URL must be absolute HTTPS; token is a service PAT.
DOC0_URL=https://doc0.siax.io
DOC0_SERVICE_TOKEN=
# The DOC0 service identity's own actor id (token subject). DOC0 rejects an
# x-actor-id it has no actor_grant for; 0pt keeps the end-user actor in its own
# journal and lets the DOC0 call act as the service.
DOC0_SERVICE_ACTOR_ID=
# Maximum accepted upload size (bytes). Default 25 MiB.
OPT_MAX_UPLOAD_BYTES=26214400
# Interim AI path: self-hosted Ollama on the SIAX decision server
# (server-decision-01). Internal only; no provider key and no per-token cost.
# Plain HTTP is accepted only for loopback/RFC1918/tailnet hosts.
DECISION_OLLAMA_URL=http://100.96.133.104:11434
DECISION_OLLAMA_MODEL=hermes-qwen3.5:9b
# INF0 AI gateway (model calls). No provider key is ever placed in this repo.
# Live path today: the LiteLLM gateway (OpenAI-compatible). The native
# /v1/inf0/* surface is contract-only; INF0_URL is used as a fallback.
INF0_GATEWAY_URL=https://ai-gateway.siax.io/v1
INF0_GATEWAY_LITELLM_KEY=
INF0_GATEWAY_PROVIDER_KIND=external
INF0_URL=https://api.inf0.siax.io
INF0_API_TOKEN=
# PAY0 (optional; Stripe is used directly for credit checkout today).
PAY0_URL=https://pay0.siax.io
PAY0_API_TOKEN=
# N0D / ST0RE (used by the broader pipeline; not required by the routes above).
N0D_URL=https://api.n0d.siax.io
N0D_API_TOKEN=
ST0RE_URL=https://api.st0re.siax.io
ST0RE_API_TOKEN=
# OpenFGA project authorization. Without all four values, document routes fail
# closed with PROJECT_AUTHORIZATION_NOT_CONFIGURED.
OPENFGA_API_URL=https://fga.siax.io
OPENFGA_STORE_ID=
OPENFGA_MODEL_ID=
OPENFGA_AUTH_TOKEN=
# Transitional, server-owned project grants used only when OpenFGA is not
# configured. JSON array; actorId "*" means every verified actor in the tenant.
# OPT_PROJECT_GRANTS=[{"tenantId":"379976258701823235","projectId":"prj_demo","actorId":"*"}]
OPT_PROJECT_GRANTS=
# Model policy for the document workbench. Without this, structuring answers
# MODEL_POLICY_NOT_CONFIGURED.
OPT_ALLOWED_MODELS=
OPT_ALLOWED_DATA_CLASSES=none,low
OPT_MODEL_DESTINATION=external
OPT_EXTERNAL_TRANSFER_ALLOWED=false
OPT_MODEL_MAX_INPUT_TOKENS=60000
OPT_MODEL_MAX_OUTPUT_TOKENS=4000
# Server-owned, versioned credit price book (JSON, one line). No price is ever
# read from a browser. Without it, credit packs/checkout/structuring answer
# PRICE_BOOK_NOT_CONFIGURED.
# {"version":"2026-11-01","currency":"SEK","packs":[{"id":"starter","credits":100,"amountMinor":9900}],"metering":{"pagesPerCredit":5,"inputTokensPerCredit":20000,"outputTokensPerCredit":4000}}
OPT_CREDIT_PRICE_BOOK=
# Pilot switch. `off` runs document structuring without a wallet; it is
# explicit (never a silent fallback), the response reports `credits: null`, and
# purchases stay disabled. Unset/`on` requires the price book + ledger.
OPT_CREDITS_MODE=
# One-time trial credits granted per tenant (server-owned, idempotent, NOT a
# payment). 0 disables. With OPT_CREDITS_MODE=on this lets reserve/settle run
# in production before any customer purchase.
OPT_TRIAL_CREDITS=0
# Central Keycloak (SIAX Portal)
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal
KEYCLOAK_EXPECTED_AUDIENCE=account
KEYCLOAK_AUTHORIZED_PARTY=siax-portal
-37
View File
@@ -1,37 +0,0 @@
# Normalize line endings for text files; keep binaries untouched.
* text=auto
# Source / config
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.css text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.py text eol=lf
*.md text eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
*.dockerfile text eol=lf
# Binaries / generated — never text-normalized
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.svg text eol=lf
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pyc binary
*.pyo binary
# Lockfiles — keep exactly as generated
pnpm-lock.yaml -text
-11
View File
@@ -1,11 +0,0 @@
# CI-14 — CODEOWNERS bor i .gitea/, aldrig i .github/ (SEC-25).
# Granskning krävs särskilt för kontrakt, deploy och migrationer.
* @sax3l
/contracts/ @sax3l
/deploy/ @sax3l
/.gitea/ @sax3l
/siax.repo.json @sax3l
/siax.config.json @sax3l
# TODO: lägg till migrationskatalogen när den finns, t.ex.
# /packages/db/migrations/ @sax3l
-206
View File
@@ -1,206 +0,0 @@
name: Deploy to Coolify
# Follows the fleet-wide pattern used by sax3l/n0d and sax3l/st0re: push to
# main triggers a POST to Coolify's deploy endpoint for this app's UUID,
# authenticated with the COOLIFY_TOKEN repo secret. Coolify already has the
# Dockerfile (build_pack: dockerfile) -- this workflow only fires the
# rebuild+redeploy, it doesn't build or push an image itself.
#
# STATUS (verified 2026-11-02): LIVE. The Coolify application exists
# (dockerfile build pack, git.siax.io/sax3l/0pt, branch main) and its UUID is
# supplied by the repository variable COOLIFY_APP_ID; COOLIFY_TOKEN and the
# registry/cosign secrets are set. A push to main, or a manual
# workflow_dispatch of this file, has been proven to verify the signed
# attestation for the exact commit and then return HTTP 200 from Coolify.
# The placeholder guard below remains as a fail-closed guard, not as a TODO.
# Operational procedure and verification live in docs/runbook/deploy.md.
on:
push:
branches: [main]
workflow_dispatch:
# Never let two deploys race the same Coolify app. Unlike CI we do NOT cancel an
# in-flight deploy (it may already be restarting the container); the newest
# commit still wins because Coolify builds from the branch head.
concurrency:
group: deploy-0pt-main
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
COOLIFY_APP_ID: ${{ vars.COOLIFY_APP_ID || 'TODO-no-coolify-app-provisioned-yet' }}
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
steps:
# -----------------------------------------------------------------
# SIGNED-ARTIFACT GATE (added 2026-09-14, same pattern as
# sax3l/cl0ud + sax3l/st0re's fix/wire-signed-digest-to-deploy; this
# job's own wait-for-signed-artifact polling logic below predates and
# is independent of the 2026-09-15 G17 migration, and is KEPT as-is
# here -- it is a real, working, 0pt-specific mechanism, not part of
# what that migration delegates to siax-templates): once the guard
# step below stops failing (i.e. once a real Coolify app exists for
# 0pt and COOLIFY_APP_ID is filled in), this job will REFUSE to call
# Coolify at all unless a verified attestation exists in the registry
# for the EXACT commit being deployed. `.gitea/workflows/release.yml`
# is the piece that builds, SBOMs, scans, attests, and pushes that
# image (rewritten 2026-09-15 to delegate to sax3l/siax-templates'
# G17 reference hook -- see `.siax/hooks/README.md`).
#
# VERIFY STEP CHANGED 2026-09-15 (part of the G17 migration): the old
# release.yml called `cosign sign` on the bare digest, so this job's
# gate used `cosign verify`. The new release.yml calls `cosign attest`
# instead -- a full DSSE-signed in-toto Statement binding digest +
# source SHA + build identity + environment + SBOM hash (ADR-W16-003
# bar items 4/5), not just a bare-digest signature. `cosign verify`
# against a `cosign attest`-only artifact fails with "no matching
# signatures" (attestations and signatures are stored/verified
# separately in cosign's model) -- so this step now calls
# `cosign verify-attestation --type <predicate-type>` instead, the
# same command `.siax/hooks/G17.sh` itself already runs and requires
# to PASS before it will submit evidence to AUD0.
#
# HONEST LIMITATION (identical to cl0ud/st0re/h0st's writeup):
# `siax.config.json`'s `deploy.target: "coolify"` and this file's own
# `/api/v1/deploy` call assume a `dockerfile` build-pack Application
# once provisioned -- same as cl0ud/st0re/h0st, Coolify would clone
# this repo and run its OWN independent `docker build`, never pulling
# the image release.yml pushed and signed, and `/api/v1/deploy` has no
# digest parameter. True digest-pinning would need 0pt's eventual
# Coolify app migrated to the separate "Docker Image" resource type --
# a production infrastructure decision that hasn't been made yet
# (0pt isn't even provisioned on Coolify at all as of this writing;
# see the guard step below and docs/runbook/deploy.md §1), not
# something this CI-YAML change can or should decide.
#
# So: what this DOES guarantee, from the day 0pt's Coolify app exists
# -- Coolify is only ever told to rebuild+redeploy a commit whose
# release-chain artifact was built, SBOM'd, signed, and had its
# signature RE-verified in THIS job. What it does NOT guarantee --
# bit-identical bytes between the signed image and whatever Coolify's
# own rebuild produces from the same source.
# -----------------------------------------------------------------
- name: Guard against unset token / placeholder app id
run: |
if [ -z "${{ secrets.COOLIFY_TOKEN }}" ]; then
echo "::error::COOLIFY_TOKEN secret is not set on this repo -- cannot redeploy."
exit 1
fi
if [ "$COOLIFY_APP_ID" = "TODO-no-coolify-app-provisioned-yet" ]; then
echo "::error::No Coolify app has been provisioned for 0pt yet. Create the app in the Coolify UI (cloud.siax.io), copy its UUID into COOLIFY_APP_ID in this file, and set the COOLIFY_TOKEN repo secret before this workflow can deploy anything."
exit 1
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve this commit's release-chain tag (same scheme as release.yml)
id: sha
run: |
set -euo pipefail
short="$(git rev-parse --short=12 HEAD)"
echo "short=$short" >> "$GITHUB_OUTPUT"
echo "image_ref=${IMAGE_REPO}:sha-$short" >> "$GITHUB_OUTPUT"
- name: Docker login (git.cloud.siax.io) — read access to check release.yml's output
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
registry_host="$(echo "$IMAGE_REPO" | cut -d/ -f1)"
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
- name: Install cosign v3.1.3 (pinned, checksum-verified — never :latest)
run: |
set -euo pipefail
cd /tmp
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign_checksums.txt
grep 'cosign-linux-amd64$' cosign_checksums.txt | sha256sum -c -
install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
cosign version
- name: Wait for release.yml's signed artifact for THIS commit, then verify (fail closed)
id: verify
env:
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
SHORT: ${{ steps.sha.outputs.short }}
PREDICATE_TYPE: https://siax.io/attestations/g17-release-evidence/v1
run: |
set -euo pipefail
test -f cosign.pub || { echo "::error::cosign.pub not found at repo root — cannot verify, refusing to deploy"; exit 1; }
# release.yml runs as a SEPARATE workflow triggered by the same push
# (Gitea/GitHub Actions has no cross-workflow-file `needs:`), so this
# job cannot depend on that job directly — it polls the registry for
# the artifact release.yml produces instead. Bounded: a genuinely
# broken/skipped release run fails this job loudly rather than
# hanging forever.
#
# The loop retries TWO distinct not-ready states (measured live on
# run 2265dbb 2026-09-15, under the OLD bare-signature gate — the
# same race applies to attestation-attach timing today): tag not
# pushed yet (release still building) vs. tag pushed but the
# attestation not attached yet (release between push and
# `cosign attest`).
attempt=0
max_attempts=40
digest=""
while true; do
attempt=$((attempt + 1))
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::no signed image found at $IMAGE_REF after $max_attempts attempts (10 min) — release.yml has not produced a signed artifact for sha-$SHORT. Refusing to deploy an unverified rebuild."
cat /tmp/pull.log 2>/dev/null || true
cat /tmp/verify.log 2>/dev/null || true
exit 1
fi
if ! docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; then
echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..."
sleep 15
continue
fi
# Read back the digest the registry actually stored for this tag —
# same filter release.yml itself uses (RepoDigests can hold multiple
# entries; never trust index 0) — and verify THAT digest, never the
# mutable tag alone, so a tag reassigned after signing can't slip past.
repo_digest="$(docker inspect --format='{{json .RepoDigests}}' "$IMAGE_REF" \
| node -e "const rd=JSON.parse(require('fs').readFileSync(0,'utf8')); const prefix=process.env.IMAGE_REPO+'@'; const m=rd.find(r=>r.startsWith(prefix)); if(!m){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(m);")"
digest="${repo_digest##*@}"
echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; }
echo "verifying cosign attestation on ${IMAGE_REPO}@${digest} ..."
if cosign verify-attestation --key cosign.pub --type "$PREDICATE_TYPE" "${IMAGE_REPO}@${digest}" >/tmp/verify.log 2>&1; then
break
fi
if grep -qE "no matching (attestations|signatures)( found)?" /tmp/verify.log; then
echo "image present but attestation not attached yet (attempt $attempt/$max_attempts) — waiting for release.yml's cosign attest..."
sleep 15
continue
fi
echo "::error::cosign verify-attestation FAILED for ${IMAGE_REPO}@${digest} (not a not-ready state — refusing to deploy)"
cat /tmp/verify.log
exit 1
done
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "SIGNED ARTIFACT VERIFIED: ${IMAGE_REPO}@${digest} — authorizing Coolify redeploy of sha-$SHORT"
- name: Trigger Coolify redeploy
env:
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
VERIFIED_DIGEST: ${{ steps.verify.outputs.digest }}
run: |
# See the SIGNED-ARTIFACT GATE comment above: Coolify's /deploy has
# no digest/image param to pass, so $VERIFIED_DIGEST is not (and
# cannot be) sent to Coolify — it is recorded here as the
# provenance proof that authorized this specific redeploy call.
echo "authorized by verified digest: $VERIFIED_DIGEST"
curl -fsS -X POST \
"https://cloud.siax.io/api/v1/deploy?uuid=${COOLIFY_APP_ID}&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN" \
-o /dev/null -w 'coolify deploy HTTP %{http_code}\n'
-330
View File
@@ -1,330 +0,0 @@
name: CI (SIAX Cloud)
# Self-hosted CI on SIAX Cloud (Gitea Actions, runners siax-runner-1/2).
# Quality gate for the Next.js 16 web console + the 0pt capability layer.
#
# * typecheck (BLOCKING) -> `tsc -p tsconfig.check.json` over src/ + tests/.
# * test (BLOCKING) -> `vitest run`.
# * lint (ADVISORY) -> `eslint src tests` (legacy v0-generated noise
# is inherited debt; continue-on-error so it never blocks).
# * build (BLOCKING) -> `next build`.
#
# * security (BLOCKING) -> `trivy fs` over the repo; fails the build on
# HIGH/CRITICAL vulns or detected secrets. This is a REQUIRED
# protected-branch context -- see the job's own comment.
#
# Nightly re-runs live in nightly.yml; security.yml is the dedicated
# push/PR/weekly scanner (this file is push/PR-only).
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch:
# One run per ref; a branch push that also updates a PR used to start two full
# quality runs (and, on main, two deploy chains). Cancel the superseded one.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0)
run: |
corepack enable
corepack prepare pnpm@9 --activate
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Typecheck (tsc over src/ + tests/ -- blocking)
run: pnpm run typecheck
# UI-09: 0pt carried 82 axe color-contrast violations (impact=serious), the
# estate's largest single a11y defect, and every one came from a Tailwind
# alpha modifier on a text colour. The unmodified token measures 4.73:1
# light / 7.63:1 dark and passes AA; every faded variant fails. Blocking,
# because a contrast regression is invisible in review.
- name: Text contrast shape (WCAG 2.2 AA 1.4.3 -- blocking)
run: pnpm run check:contrast
- name: Test (vitest -- blocking)
run: pnpm run test
- name: Lint (eslint -- advisory; legacy tree has pre-existing noise)
continue-on-error: true
run: pnpm run lint
- name: Build (next build -- blocking quality gate)
run: pnpm run build
# REAL-DEPENDENCY integration tests (Master-DoD §48: "integration tests ...
# Mocks alone count as insufficient"; §10 Search/Analytics). Runs
# tests/integration/* against a LIVE OpenSearch + LIVE ClickHouse started as
# service containers.
#
# SERVICE-CONTAINER DISCIPLINE is copied from sax3l/st0re's ci.yml, which
# documents three measured defects on these runners (alias collision across
# repos, sibling-run collision behind one alias, and DNS round-robin):
# * repo-unique aliases (opt-ci-opensearch / opt-ci-clickhouse),
# * SELF-IDENTIFYING containers — OpenSearch carries a run-unique
# cluster.name; ClickHouse carries run-unique credentials — so the
# select step can positively identify THIS run's container,
# * enumerate every IP behind the alias (getent ahostsv4), keep the one
# that proves the run identity, freeze it into the URL env var,
# * no match = hard failure, never a silent fallback or skip.
integration:
# Gitea currently ignores workflow concurrency. A branch push followed by
# its PR synchronize event otherwise starts TWO 512 MiB OpenSearch JVMs
# and ClickHouse suites on the same runner fleet at once; both timed out
# creating their first index (runs 28904/28906). Keep the blocking LIVE
# integration proof on every PR, on main push, and on manual dispatch;
# ordinary branch pushes still run blocking quality/security.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
services:
opt-ci-opensearch:
image: opensearchproject/opensearch:2.11.1
env:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
# Identity badge: only THIS run's container answers with this
# cluster_name (the OpenSearch image maps dotted env keys to
# settings).
cluster.name: opt_ci_${{ github.run_id }}_${{ github.run_attempt }}
# Throwaway CI cluster: never let the runner's disk fill turn indices
# read-only (the 403 index_create_block_exception seen in run 388536).
cluster.routing.allocation.disk.threshold_enabled: "false"
# `--workdir` is REQUIRED (measured 2026-08-31, PR #25 CI run 7745,
# job "integration"): this runner's act_runner (v0.6.1) merges the
# JOB's WorkingDir (`/workspace/sax3l/0pt`) into every SERVICE
# container's config, clobbering the image's own `WORKDIR
# /usr/share/opensearch`. OpenSearch's ENTRYPOINT is the
# WORKDIR-relative `./opensearch-docker-entrypoint.sh`, so with the
# wrong cwd the container failed with "exec:
# \"./opensearch-docker-entrypoint.sh\": stat ...: no such file or
# directory" before it ever bound a port. ClickHouse's entrypoint is
# an absolute path, so it was unaffected by the same defect — do not
# assume a green ClickHouse service means service-container options
# are fine in general.
options: >-
--workdir /usr/share/opensearch
--health-cmd "curl -fsS http://localhost:9200/_cluster/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 60
opt-ci-clickhouse:
image: clickhouse/clickhouse-server:24.12
env:
CLICKHOUSE_USER: opt_ci
# Identity badge: run-unique password — an authenticated SELECT only
# succeeds against THIS run's container. CI-scoped throwaway value,
# not a secret (exists only for the lifetime of the job).
CLICKHOUSE_PASSWORD: ci_${{ github.run_id }}_${{ github.run_attempt }}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
options: >-
--health-cmd "wget -qO- http://localhost:8123/ping || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9
run: |
corepack enable
corepack prepare pnpm@9 --activate
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Select THIS run's OpenSearch by identity (st0re ci.yml pattern)
run: |
set -eu
WANT="opt_ci_${{ github.run_id }}_${{ github.run_attempt }}"
FOUND=""
for ip in $(getent ahostsv4 opt-ci-opensearch | awk '{print $1}' | sort -u); do
NAME=$(curl -fsS --max-time 5 "http://$ip:9200/" | sed -n 's/.*"cluster_name" *: *"\([^"]*\)".*/\1/p' || true)
echo "candidate $ip cluster_name=${NAME:-<none>}"
if [ "$NAME" = "$WANT" ]; then FOUND="$ip"; fi
done
[ -n "$FOUND" ] || { echo "FATAL: no OpenSearch behind the alias identifies as $WANT"; exit 1; }
# The single-node test cluster inherits the runner's disk watermark.
# On a busy runner the flood-stage watermark makes every new index
# read-only and the suite fails with HTTP 403
# index_create_block_exception (measured on run 388536). The container
# is throwaway CI state, so disable the disk threshold and PROVE that
# index creation works before the blocking tests run.
for attempt in 1 2 3 4 5; do
# Disabling the watermark alone does NOT clear a cluster that is
# already read-only: the flood stage sets
# cluster.blocks.read_only_allow_delete and every write keeps
# returning 403. Clear that block explicitly every attempt.
curl -sS --max-time 10 -X PUT "http://$FOUND:9200/_cluster/settings" \
-H 'Content-Type: application/json' \
-d '{"persistent":{"cluster.routing.allocation.disk.threshold_enabled":false,"cluster.blocks.read_only_allow_delete":null}}' >/tmp/os-settings.json || true
cat /tmp/os-settings.json 2>/dev/null || true
probe_status=$(curl -sS --max-time 10 -o /tmp/os-probe.json -w '%{http_code}' -X PUT "http://$FOUND:9200/opt-ci-probe-${{ github.run_id }}")
if [ "$probe_status" = "200" ]; then
curl -fsS --max-time 10 -X DELETE "http://$FOUND:9200/opt-ci-probe-${{ github.run_id }}" >/dev/null || true
echo "OpenSearch accepts index creation"
break
fi
echo "index creation not ready (attempt $attempt/5, HTTP $probe_status) — waiting"
cat /tmp/os-probe.json 2>/dev/null || true
[ "$attempt" = "5" ] && { echo "FATAL: OpenSearch still refuses index creation"; exit 1; }
sleep 10
done
echo "OPENSEARCH_URL=http://$FOUND:9200" >> "$GITHUB_ENV"
- name: Select THIS run's ClickHouse by identity
run: |
set -eu
PASS="ci_${{ github.run_id }}_${{ github.run_attempt }}"
FOUND=""
for ip in $(getent ahostsv4 opt-ci-clickhouse | awk '{print $1}' | sort -u); do
OK=$(curl -fsS --max-time 5 -u "opt_ci:$PASS" "http://$ip:8123/?query=SELECT%201" || true)
echo "candidate $ip auth_select=${OK:-<denied>}"
if [ "$OK" = "1" ]; then FOUND="$ip"; fi
done
[ -n "$FOUND" ] || { echo "FATAL: no ClickHouse behind the alias accepts this run's credentials"; exit 1; }
echo "CLICKHOUSE_URL=http://$FOUND:8123" >> "$GITHUB_ENV"
echo "CLICKHOUSE_AUTH=opt_ci:$PASS" >> "$GITHUB_ENV"
# The anti-skip guard here used to grep vitest's HUMAN output:
#
# grep -qE "skipped" && ! grep -qE "Tests [0-9]+ passed"
#
# vitest colours that summary, so the real line on this runner is
# ESC[2m Tests ESC[22m ESC[1mESC[32m97 passedESC[39m ... — ANSI escapes
# sit between "Tests " and the digits, so the two-spaces-then-digit
# pattern can never match. The first grep still matched "skipped", so
# the guard fired on ANY run containing a single skipped test while
# printing "integration suites skipped instead of running" — a false
# statement. Measured 2026-09-08, run 13911 job 31183: 13 files / 97
# tests PASSED, 1 file / 2 tests skipped → FATAL anyway.
#
# It survived review because it is correct locally: redirecting to a
# file disables vitest's colour, so the pattern matches on a developer
# machine and only misfires under CI.
#
# Replaced with the JSON reporter — structured and colourless. A/B
# measured on real reports, BOTH directions:
# all-skipped (0 passed / 99 pending) → RED (protection preserved)
# 945 passed + 184 skipped → GREEN (false red removed)
- name: Integration tests against LIVE OpenSearch + ClickHouse (blocking)
run: |
set -eu
# Env MUST be present here — the suites self-skip without it, and a
# skipped suite must never masquerade as a green integration gate
# (Master-DoD §56 status honesty).
[ -n "${OPENSEARCH_URL:-}" ] && [ -n "${CLICKHOUSE_URL:-}" ]
pnpm vitest run tests/integration \
--reporter=default \
--reporter=json --outputFile.json=/tmp/itest.json
node -e '
const fs = require("fs");
const p = "/tmp/itest.json";
if (!fs.existsSync(p)) {
console.error("FATAL: vitest produced no JSON report"); process.exit(1);
}
const r = JSON.parse(fs.readFileSync(p, "utf8"));
console.log(`integration: ${r.numPassedTests} passed, ${r.numPendingTests} skipped, ${r.numFailedTests} failed, ${r.numTotalTests} total`);
if (r.numFailedTests > 0) {
console.error("FATAL: integration tests failed"); process.exit(1);
}
if (r.numPassedTests === 0) {
console.error("FATAL: no integration test actually executed — every suite skipped");
process.exit(1);
}
'
# BLOCKING. This job publishes the protected-branch status check context
# "CI (SIAX Cloud) / security (push)", which branch protection on `main`
# lists as required alongside "CI (SIAX Cloud) / quality (push)".
#
# It previously ran Trivy with `--exit-code 0`, which made the REQUIRED
# context incapable of ever reporting failure. Measured 2026-09-08 on
# main @4514d97: a planted CRITICAL secret (AWS secret access key) was
# DETECTED by the scanner and the job still exited 0 -- i.e. the gate
# reported success while holding a critical finding in hand. The same
# command with `--exit-code 1` exits 1 on that planted secret and exits 0
# on the clean tree, so this flag is the whole difference between a real
# gate and a decorative one.
#
# KNOWN COVERAGE GAP (not fixed here, needs an owner decision):
# services/scrapers/requirements.txt pins nothing (`>=` ranges only), so
# Trivy's pip scanner cannot resolve versions and silently omits the file
# from the scan entirely -- it is not a target in the report at all.
# Pinning those same floors with `==` surfaces 3 HIGH findings today
# (aiohttp CVE-2025-69223, aiohttp CVE-2026-69244, lxml CVE-2026-41066).
# Do NOT paper over this with `--skip-dirs`; that would convert a
# measurable blind spot into declared policy.
security:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# `siax/sbom/` är CycloneDX-evidenens för REDAN BYGGDA images (release-kedjan),
# inte källberoenden. Att scanna dem här återrapporterar gamla image-versioner
# (next 16.3.x, sharp 0.35.4) och blockerar källfixar som redan är patchade.
# Image-SBOM:erna verifieras i release/image-pipelinen.
- name: Trivy scan (vuln + secrets -- BLOCKING, fails on HIGH/CRITICAL)
run: |
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
sonar:
# SonarQube Community Build har inget separat PR-analysläge (en enda "main"-gren
# per projekt) — en analys från en PR-branch eller en tagg-push som inte pekar på
# main skulle skriva över kvalitetshistoriken. Kör därför bara
# vid push till main.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube analysis (self-hosted sonar.siax.dev)
env:
SONAR_HOST_URL: https://sonar.siax.dev
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
set -euo pipefail
test -n "$SONAR_TOKEN" || { echo "::error::SONAR_TOKEN repo secret missing"; exit 1; }
# Jobbet kör i en container på act_runner; "docker run -v \"\$PWD:...\""
# skulle montera VÄRDENS (tomma) sökväg. Kopiera in källträdet istället.
cid=$(docker create -e SONAR_HOST_URL -e SONAR_TOKEN -w /usr/src \
sonarsource/sonar-scanner-cli:latest \
sonar-scanner -Dsonar.projectKey=0pt -Dsonar.host.url="$SONAR_HOST_URL")
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp . "$cid":/usr/src
docker start -a "$cid"
design-conformance:
name: Component conformance (blocking)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Checkout canonical siax-standard (SSOT)
uses: actions/checkout@v4
with:
repository: admin/siax-standard
token: ${{ secrets.SIAX_CI_TOKEN }}
path: siax-standard-ssot
- name: Component conformance (blocking)
run: |
node --input-type=module -e "import('./siax-standard-ssot/packages/doctor/src/commands/component-conformance.mjs').then(m=>{process.exit(m.run(['--repo', process.env.GITHUB_WORKSPACE, '--allow-path-prefix', 'siax-standard-ssot/', '--fail-on=block']))})"
-18
View File
@@ -1,18 +0,0 @@
name: Health Check (SIAX Cloud)
on:
workflow_dispatch:
schedule:
- cron: "0 6 * * 1"
jobs:
health:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Required files
run: |
for f in README.md LICENSE .gitignore; do [ -f "$f" ] || echo "::warning file=$f,title=Missing::$f is missing"; done
[ -f .gitea/workflows/ci.yml ] || echo "::warning title=No CI::No CI workflow found"
- name: Install Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Secret scan
run: trivy fs --scanners secret --exit-code 0 .
-22
View File
@@ -1,22 +0,0 @@
name: masterplan-lock
# Fails the build if this repo's copy of the SIAX Non-Platform masterplan
# (PLAN/MASTERPLAN_INDEX.md) is deleted or its locked architecture
# principles are stripped out. Dependency-free (POSIX sh) so it needs no
# language toolchain. See scripts/check-masterplan-lock.sh and
# sax3l/siax-masterplan.
on:
push:
branches: ["**"]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Masterplan lock check
run: sh scripts/check-masterplan-lock.sh
-56
View File
@@ -1,56 +0,0 @@
name: Nightly (SIAX Cloud)
# Nightly full-gate re-run so CI exercises the repo even without pushes:
# quality gate + advisory security scan. Fails loudly on regressions
# introduced outside push-triggered runs (e.g. dependency drift).
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # daily 04:00 UTC
concurrency:
group: nightly-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Enable corepack + pin pnpm@9
run: |
corepack enable
corepack prepare pnpm@9 --activate
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Typecheck (blocking)
run: pnpm run typecheck
- name: Test (blocking)
run: pnpm run test
- name: Lint (advisory)
continue-on-error: true
run: pnpm run lint
- name: Build (blocking)
run: pnpm run build
security:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# A nightly scan exists to catch CVEs published against code that did
# not change. With `--exit-code 0` it could never report failure, so it
# could never raise that alarm -- the one job whose entire purpose is
# detection was the one guaranteed to stay green. Fixed 2026-09-08.
- name: Trivy fs scan (vuln + secrets -- fails on HIGH/CRITICAL)
run: |
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
-77
View File
@@ -1,77 +0,0 @@
name: Production smoke (0pt.app)
# Automated end-to-end delivery verification. Every 30 minutes (and on demand)
# this checks that the live service is actually ready and that the running
# container is the commit that main points at — so a green deploy is never
# confused with a healthy, current service.
on:
workflow_dispatch:
schedule:
- cron: "*/30 * * * *"
concurrency:
group: prod-smoke
cancel-in-progress: true
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Readiness (retry on a fresh deploy)
run: |
set -eu
for attempt in 1 2 3 4 5 6; do
body="$(curl -fsS --max-time 15 https://0pt.app/api/ready || true)"
echo "attempt $attempt: ${body:-<no response>}"
if printf '%s' "$body" | grep -q '"ready":true'; then
exit 0
fi
sleep 20
done
echo "::error::0pt.app/api/ready never reported ready:true"
exit 1
- name: Health envelope
run: |
set -eu
body="$(curl -fsS --max-time 15 https://0pt.app/api/health)"
echo "$body"
printf '%s' "$body" | grep -q '"db":"ok"' || { echo "::error::database is not ok"; exit 1; }
printf '%s' "$body" | grep -q '"stripe":"ok"' || { echo "::error::stripe is not ok"; exit 1; }
- name: Deployed commit equals main
env:
SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }}
run: |
set -eu
version="$(curl -fsS --max-time 15 https://0pt.app/api/version)"
echo "$version"
running="$(printf '%s' "$version" | sed -n 's/.*"commit":"\([0-9a-f]*\)".*/\1/p')"
[ -n "$running" ] || { echo "::error::/api/version returned no commit"; exit 1; }
if [ -z "${SIAX_CI_TOKEN:-}" ]; then
echo "::warning::SIAX_CI_TOKEN is not set — cannot compare with main; commit $running is live"
exit 0
fi
expected="$(curl -fsS --max-time 15 \
-H "Authorization: token ${SIAX_CI_TOKEN}" \
"https://git.siax.io/api/v1/repos/admin/0pt/commits?limit=1" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["sha"])')"
echo "running=$running expected(main)=$expected"
[ -n "$expected" ] || { echo "::error::could not read main SHA from Gitea"; exit 1; }
[ "$running" = "$expected" ] || {
echo "::error::production is $running but main is $expected — deploy did not land"
exit 1
}
- name: Document pipeline surface responds (auth required, never 404/5xx)
run: |
set -eu
for path in /api/0pt/documents/upload /api/0pt/documents/structure /api/0pt/documents/models /api/0pt/credits/balance; do
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "https://0pt.app${path}")"
echo "$path -> $code"
case "$code" in
401|405) ;; # unauthenticated or method guard: the route exists
*) echo "::error::$path returned $code (expected 401/405)"; exit 1 ;;
esac
done
-184
View File
@@ -1,184 +0,0 @@
name: Release chain (G17, ADR-W16-003)
# Builds + pushes the 0pt image, then hands off to the shared
# sax3l/siax-templates G17 reference hook for every ADR-W16-003 bar item
# 1-7 (pinned-tool install, SBOM, blocking vuln scan, in-toto attestation,
# scoped-key signing, pubkey verification, AUD0/ST0RE evidence storage).
#
# REPLACES this repo's own ad hoc "P007 pattern" release chain (added
# 2026-09-14). An audit (2026-09-15) found that chain failed most of the
# real promotion bar it claimed to meet: syft/cosign installed via a `curl`
# + freshly-refetched checksums file (not a pinned, out-of-band-verified
# digest -- see siax-templates' `lib/pinned-tools.sh` header for exactly
# why that is not a verified digest), NO vulnerability scan of the built
# digest at all, `cosign sign` on the bare digest only (no in-toto/SLSA
# statement, no source-SHA/build-identity/environment/SBOM-hash bound
# together, no scoped key identity), and evidence committed straight back
# into this same mutable repo (`siax/production-evidence/*.json`,
# `siax/source-bundles/*.json` -- nine such commits already existed in this
# repo's history before this change; deleted in this same PR, superseded by
# the real AUD0 write below). This file keeps ONLY what is genuinely
# 0pt-specific (image name, tag scheme, registry push, Dockerfile) and
# delegates everything else to `.siax/hooks/G17.sh` -> the shared reference
# implementation. See `.siax/hooks/README.md` for the pin status and
# required secrets (none of which were provisioned for the old gate either
# -- see that file's own note).
#
# DELIBERATELY ONE JOB. Gitea 1.26 on this fleet resolves org-level
# secrets (SIAX_CI_TOKEN below is org-level) only for the FIRST job of a
# workflow file -- learned the hard way on sax3l/h0st's and sax3l/aud0's
# own G17 migrations. 0pt does not wire a G20 post-deploy step in this
# pass at all (see `.siax/hooks/README.md` "G20 -- deliberately not wired
# yet": the app is now provisioned and `/api/version` exists, but G20 is still
# not wired in this pass), so the single-job constraint that forces G20 into its own
# workflow file elsewhere does not yet bite here -- documented anyway so a
# future PR adding G20 does not repeat the mistake.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
DOCKERFILE: Dockerfile
SIAX_TEMPLATES_REPO: sax3l/siax-templates
# PIN STATUS 2026-09-15: sax3l/siax-templates PR #51
# (feat/g17-signed-deploy-gate-adr-w16-003) MERGED to main, plus three
# real follow-up fixes on top -- #56/#57 (SPDX compound-license-
# expression evaluation, exercised live by this repo's own
# G17_LICENSE_ALLOW policy in .siax/hooks/G17.sh) and #59 (G20 retry
# logic -- not used by 0pt yet, but the pin picks up main's current tip
# rather than an older commit, matching what a fresh migration reviews).
#
# Re-pin 2026-09-25 (medveten, granskad): 9a3a0aa -> 09504e3a. Deltaet ar
# EN merge, siax-templates #138 "Upload complete release evidence and
# validate artifact receipt". Enda berorda filer under ci/v2 ar
# reference-hooks/{G17.sh, G17-ADR-W16-003.md, lib/emit-release-evidence.py,
# lib/test_emit_release_evidence.py}. Andringen ar bakatkompatibel by
# design: ST0RE-uppladdningen ar OPT-IN via G17_REQUIRE_ST0RE (default 0),
# sa en AUD0-only-konsument som 0pt kor exakt samma vag som forut.
#
# Deliberately NO branch name here: the pinned commit is checked out
# DIRECTLY (see the step below). The previous shape -- clone `main`, then
# require its HEAD to equal this pin -- turned every unrelated merge in
# siax-templates into a red release chain here (CD-03 / CI-RED-02) while
# nothing was actually unsafe. Checking the pin out directly keeps the pin
# a real supply-chain control (the gate runs exactly this reviewed commit,
# or it fails) without coupling it to someone else's branch tip.
SIAX_TEMPLATES_PINNED_SHA: 09504e3a593ee8ce0eaba606df31c349a3d52277
SIAX_TEMPLATES_DIR: /tmp/siax-templates-g17-ref
jobs:
release:
name: Build, push, G17 (SBOM/scan/attest/verify/evidence)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: https://git.siax.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, mirrored + SHA-pinned (SUP-06, same pin n0d/cl0ud/st0re/h0st already use)
with:
fetch-depth: 0
# --- fetch the shared G17 reference implementation -----------------
# Same authenticated-fetch-at-runtime mechanism this repo's OLD
# release.yml already used SIAX_CI_TOKEN for (the evidence-commit
# push a prior PR deleted) -- see .siax/hooks/G17.sh's header for why
# NOT a `uses:` reusable workflow / submodule / vendored copy. Fails
# closed if the exact commit this adapter was reviewed against cannot
# be checked out (removed or rewritten upstream); an unrelated merge
# on siax-templates' own main is NOT a failure and never was a real
# signal.
- name: Fetch siax-templates G17 reference (pinned commit)
env:
SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }}
run: |
set -euo pipefail
rm -rf "$SIAX_TEMPLATES_DIR"
mkdir -p "$SIAX_TEMPLATES_DIR"
git -C "$SIAX_TEMPLATES_DIR" init --quiet
git -C "$SIAX_TEMPLATES_DIR" remote add origin \
"https://oauth2:${SIAX_CI_TOKEN}@git.siax.io/${SIAX_TEMPLATES_REPO}.git"
# Ask for the pinned commit by SHA -- one shallow commit, no branch
# involved. A server with uploadpack.allowReachableSHA1InWant off
# refuses that, so fall back to fetching the branches and taking the
# same commit out of them. Either way the work tree ends up at
# exactly $SIAX_TEMPLATES_PINNED_SHA, never at a branch tip.
if ! git -C "$SIAX_TEMPLATES_DIR" fetch --quiet --depth 1 origin "$SIAX_TEMPLATES_PINNED_SHA"; then
echo "note: server refused a by-SHA fetch; falling back to fetching all branches"
git -C "$SIAX_TEMPLATES_DIR" fetch --quiet origin
fi
if ! git -C "$SIAX_TEMPLATES_DIR" -c advice.detachedHead=false \
checkout --quiet --detach "$SIAX_TEMPLATES_PINNED_SHA"; then
echo "::error::siax-templates commit $SIAX_TEMPLATES_PINNED_SHA not found in $SIAX_TEMPLATES_REPO -- the reviewed commit was removed or rewritten upstream. Re-review siax-templates and update SIAX_TEMPLATES_PINNED_SHA deliberately."
exit 1
fi
actual_sha="$(git -C "$SIAX_TEMPLATES_DIR" rev-parse HEAD)"
if [ "$actual_sha" != "$SIAX_TEMPLATES_PINNED_SHA" ]; then
echo "::error::checked-out siax-templates HEAD is $actual_sha, expected the pinned $SIAX_TEMPLATES_PINNED_SHA -- refusing to run an unreviewed version of the gate."
exit 1
fi
chmod +x "$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/"*.sh "$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/lib/"*.sh
echo "siax-templates G17 reference @ $actual_sha (pinned commit, checked out directly)"
- name: Resolve SHA + tag (never :latest)
id: sha
run: |
set -euo pipefail
full="$(git rev-parse HEAD)"
short="$(git rev-parse --short=12 HEAD)"
echo "full=$full" >> "$GITHUB_OUTPUT"
echo "short=$short" >> "$GITHUB_OUTPUT"
echo "image_tag=sha-$short" >> "$GITHUB_OUTPUT"
- name: Docker login (git.cloud.siax.io)
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
registry_host="$(echo "$IMAGE_REPO" | cut -d/ -f1)"
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
- name: Build
env:
IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
run: |
set -euo pipefail
docker build -f "$DOCKERFILE" -t "${IMAGE_REPO}:${IMAGE_TAG}" .
- name: Push (immutable sha-<12> tag, never :latest)
env:
IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
run: docker push "${IMAGE_REPO}:${IMAGE_TAG}"
# --- G17: everything else, delegated -------------------------------
- name: G17 (bar items 1-7, delegated to siax-templates)
env:
ARTIFACT_COMMIT_SHA: ${{ steps.sha.outputs.full }}
ARTIFACT_IMAGE_TAG: ${{ steps.sha.outputs.image_tag }}
CI_RUN_ID: ${{ github.run_id }}
CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
G17_COSIGN_KEY: /tmp/0pt-g17-cosign.key
G17_COSIGN_KEY_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
AUD0_INGEST_TOKEN: ${{ secrets.AUD0_INGEST_TOKEN }}
AUD0_TENANT_ID: ${{ secrets.AUD0_TENANT_ID }}
run: |
set -euo pipefail
echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > "$G17_COSIGN_KEY"
trap 'rm -f "$G17_COSIGN_KEY"' EXIT
bash .siax/hooks/G17.sh
# NOTE: deliberately NO `actions/upload-artifact@v4` step here.
# sax3l/cl0ud's release.yml (run 23340, 2026-09-14) documents that
# this Gitea instance's act_runner does not reliably support
# upload-artifact@v4 even with continue-on-error: true, and that it
# can abort the whole job before later steps run. G17.sh's own AUD0
# submission (bar item 7, step above) is already the durable evidence
# record; a convenience local copy is not worth re-triggering that
# known breakage.
-43
View File
@@ -1,43 +0,0 @@
name: Security Scan (SIAX Cloud)
# Security scanning on every push/PR + weekly re-run.
#
# This job runs the same Trivy invocation as ci.yml's `security` job and
# previously carried the same defect: `--exit-code 0` meant it could report
# success while holding a CRITICAL finding (measured 2026-09-08 with a
# planted AWS secret key -- detected, still exit 0). Its context
# "Security Scan (SIAX Cloud) / security" is NOT in branch protection's
# required list, so this one never gated a merge either way; it is fixed
# here so a green tick on this workflow means what a reader assumes it
# means. See ci.yml's `security` job for the measurement and for the
# services/scrapers unpinned-requirements coverage gap.
#
# NOTE: findings on services/scrapers/* (FlareSolverr/Tor/curl_cffi) are
# expected and out of scope for CI gating -- kept per the account owner's
# dated, attributed decision (Simon Axelsson, 2026-08-11, interactive
# prompt; no rationale beyond "keep it" given or invented). See SECURITY.md
# and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context.
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch:
schedule:
- cron: "0 5 * * 1" # weekly Monday re-scan
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
security:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Trivy fs scan (vuln + secrets -- fails on HIGH/CRITICAL)
run: |
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 1 --skip-dirs 'siax/sbom' .
-44
View File
@@ -1,44 +0,0 @@
name: VehIQ OLTP extractor (0pt ingestion)
# P070 — scheduled poll of app.dealer_stock_entry/app.deal (VehIQ, server10)
# into opt_dataset_projection (ClickHouse), via the watermark extractor in
# src/ingestion/vehiq-oltp-ingest-runner.ts. See docs/P070_VEHIQ_OLTP_
# EXTRACTOR.md for the full design and "what activating this requires".
#
# SAFE BY DEFAULT: VEHIQ_INGEST_MODE defaults to "preview" (read-only, never
# commits a checkpoint, never writes) below. With no VEHIQ_SOURCE_DATABASE_URL
# secret configured on this repo, the script logs "not_configured" and exits
# 0 — this workflow can be left enabled indefinitely with zero effect on
# anything until BOTH the secret is added AND VEHIQ_INGEST_MODE is changed
# to "run". Neither is done by this workflow file itself.
on:
workflow_dispatch:
schedule:
- cron: "*/15 * * * *"
jobs:
extract:
runs-on: ubuntu-latest
env:
VEHIQ_SOURCE_DATABASE_URL: ${{ secrets.VEHIQ_SOURCE_DATABASE_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPT_ANALYTICS_CLICKHOUSE_BASE_URL: ${{ secrets.OPT_ANALYTICS_CLICKHOUSE_BASE_URL }}
OPT_ANALYTICS_CLICKHOUSE_BASIC_AUTH: ${{ secrets.OPT_ANALYTICS_CLICKHOUSE_BASIC_AUTH }}
OPT_ANALYTICS_CLICKHOUSE_DATABASE: opt_analytics
# Activation gate — see docs/P070_VEHIQ_OLTP_EXTRACTOR.md. Flip to
# "run" only once steps 1-4 in that doc's "What activating this
# requires" section are actually done.
VEHIQ_INGEST_MODE: preview
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable corepack + pin pnpm@9
run: |
corepack enable
corepack prepare pnpm@9 --activate
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run the extractor (preview mode unless activated)
run: pnpm run vehiq:oltp-ingest
-59
View File
@@ -1,59 +0,0 @@
name: CI
# Quality gate: typecheck + test on every PR and non-main push.
# 0pt previously relied solely on the Vercel build with no pre-merge gate.
# Build is intentionally NOT run here because it needs runtime secrets
# (POSTGRES_URL, ENGINE_URL, STRIPE, RESEND) — Vercel owns the build; CI owns
# the static gate.
#
# Updated by the GitHub<->Gitea unification merge (2026-07-28). The note that
# used to sit here — "lint is not a step because this repo has no eslint
# dependency or config wired" — is no longer true: the Gitea line's Wave 2
# hardening added eslint + typescript-eslint + a flat config and repointed the
# "lint" script at `eslint src tests`. Lint now runs, ADVISORY
# (continue-on-error), matching .gitea/workflows/ci.yml: the v0-generated
# legacy tree carries pre-existing lint noise, so it must not block merges.
#
# `typecheck` (BLOCKING) is tsc over tsconfig.check.json = src/ + tests/.
# `typecheck:app` (ADVISORY) is the whole-repo tsc this workflow used to run as
# its blocking step. It was green on the GitHub line alone but reports 9
# pre-existing errors on the unified tree (app/api/0pt/* route handlers, where
# the Wave 2 Zod schemas type fields as plain `string` while the services want
# narrower unions). It is kept running, and kept visible, rather than deleted —
# see package.json's "//scripts" note. Flip it back to blocking once those 9
# are cleared.
#
# Python engine (services/api) is gated separately if/when a Python workflow
# is added.
on:
pull_request:
push:
branches-ignore: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
name: Typecheck + test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack (pnpm)
run: corepack enable
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Typecheck (tsc over src/ + tests/ -- blocking)
run: pnpm run typecheck
- name: Test (vitest -- blocking)
run: pnpm run test
- name: Typecheck whole repo (advisory -- 9 pre-existing app/ errors)
continue-on-error: true
run: pnpm run typecheck:app
- name: Lint (eslint -- advisory; legacy tree has pre-existing noise)
continue-on-error: true
run: pnpm run lint
-57
View File
@@ -1,57 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# pnpm local store (created when store-dir defaults into the project)
.pnpm-store/
# interim LocalBlobStore output (src/adapters/local-blob-store.ts)
.data/
# local blob storage (document artifacts when no object storage configured)
.opt-blobs/
# python (engine + scrapers) -- runtime artifacts, never source
__pycache__/
*.pyc
*.pyo
.venv/
.pytest_cache/
# analytics / database files
*.duckdb
*.duckdb.wal
# scraper run output (local dumps written by services/scrapers/*.py, never source)
services/scrapers/output/
# local-only environment overrides (all real values live in Infisical)
.env.local
.env.production
# parallel git worktrees (policy: hidden inside the repo, never committed)
/.worktrees/
-8
View File
@@ -1,8 +0,0 @@
verify-deps-before-run=false
include-workspace-root=true
confirmModulesPurge=false
# SIAX Cloud publishes @siax/* packages from Gitea's own npm registry.
# Only scoped @siax/* names resolve here; everything else stays on the
# public npm registry.
@siax:registry=https://git.siax.io/api/packages/sax3l/npm/
-11
View File
@@ -1,11 +0,0 @@
# Build output and local worktrees are not source.
.next/
out/
build/
dist/
node_modules/
.worktrees/
coverage/
*.min.js
*.min.css
pnpm-lock.yaml
-6
View File
@@ -1,6 +0,0 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 110
}
-30
View File
@@ -1,30 +0,0 @@
{
"exemptions": [
{
"control": "REPO-01",
"paths": [
"siax/sbom/sbom-0pt-2026-09-29.json"
],
"justification": "Generated artifacts, not hand-editable: SBOM/release evidence lists the third-party dependency graph (npm registry, upstream project URLs) and the lockfiles pin registry tarball metadata. Rewriting them would falsify supply-chain evidence."
},
{
"control": "REPO-01",
"paths": [
"AGENTS.md",
"CLAUDE.md",
"ONBOARDING.md",
"PRODUCTION_EVIDENCE.json",
"SECURITY.md",
"docs/02-architecture.md",
"docs/06-data-engine.md",
"docs/07-dependencies.md",
"docs/08-infrastructure.md",
"docs/09-build-plan.md",
"docs/P0_QUERY_SOURCE_FLOW.md",
"docs/compliance/INVENTORY.md",
"docs/compliance/SLUTRAPPORT.md"
],
"justification": "Documentation prose. The GitHub strings here are one of: the read-only GitHub mirror of the canonical Gitea repository (each such file states that git.siax.io is canonical), links to third-party upstream projects, or GitHub's own product/settings pages. None of them configures or names a SIAX git remote."
}
]
}
-1
View File
@@ -1 +0,0 @@
{"allow":[{"pathPrefix":"components/states.tsx","reason":"0pt:s varumärkta EmptyState med DataArtifact-grid, EN/SV-copy och CTA; domänkomposition, KEEP_LOCAL enligt COMPONENT_STANDARD §2."},{"pathPrefix":"components/theme-provider.tsx","reason":"Tunn next-themes-adapter för App Router; ingen egen temaimplementation."},{"pathPrefix":"components/ui/toaster.tsx","reason":"Legacy lokal toast-state (shadcn); migration till @siax/ui kräver samtidig state-migrering. KEEP_LOCAL med backlog."}]}
-14
View File
@@ -1,14 +0,0 @@
{
"schemaVersion": "1.0",
"repo": "0pt",
"generatedAt": "2026-09-23T21:12:27.075Z",
"standard": {
"canonicalRemote": "https://git.siax.io/sax3l/siax-standard.git",
"ref": "main",
"sha": "0000000000000000000000000000000000000000"
},
"repoRole": "LEGACY",
"schemaBundleDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"ownerRegistryDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"productFirewallDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
-8
View File
@@ -1,8 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# decision-usage — 0pt
schemaVersion: siax.decision-usage/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
owners: [simon@siax.io]
decisions: []
notes: GENERATED stub - tomt ar den arliga 'inga beslut materialiserade'-formen; varje beslut under osakerhet listas har (required/advisory/shadow), produkter anropar aldrig Jev/providers direkt.
-12
View File
@@ -1,12 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# des1gn-consumer — 0pt (GENERATED neutral baseline; anpassa via siax-templates)
schemaVersion: siax.des1gn-consumer/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
consumes:
'@siax/0pt-decision': 'härlett ur package.json (deklarerad workspace:*)'
'@siax/config': 'härlett ur package.json (deklarerad ^0.2.1)'
'@siax/tokens': 'härlett ur package.json (deklarerad ^0.1.2)'
'@siax/ui': 'härlett ur package.json (deklarerad ^0.4.1)'
localPrimitives: []
notes: GENERATED stub - tomt ar den arliga baseline-formen; konsumerade kanoniska DES1GN-paket (pinned) listas under consumes, kvarvarande lokala primitives motiveras i notes.
-24
View File
@@ -1,24 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.074Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# dev-profile — 0pt (neutral, kanoniskt giltig baseline; anpassa per repo via siax-templates)
schemaVersion: siax.dev-profile/v1
repoType: library
riskDefault: L2
capabilities:
stateful: false
deployable: false
publicApi: false
aiEnabled: false
userFacing: false
tenantScoped: false
handlesSecrets: false
gates:
realDatabase: required_when_changed
migrationUpgrade: required_when_changed
e2e: required_when_changed
accessibility: required_when_changed
visualRegression: required_when_changed
mutation: required_when_changed
performance: required_when_changed
release:
owner: sax3l
strategy: same-digest-promotion
-9
View File
@@ -1,9 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# feature-packs — 0pt
schemaVersion: siax.feature-packs/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
packs:
permissions: []
states: []
notes: GENERATED stub - tomt ar den arliga baseline-formen; varje pack binder 'version owner backendOwner' (kanonisk backend-agare, aldrig kopierad implementation).
-20
View File
@@ -1,20 +0,0 @@
{
"schemaVersion": "siax.hook-pin/v1",
"repo": "0pt",
"hooks": {
"G17": {
"contract": "ci/v2 G17 (Artifact security) -- ADR-W16-003 promotion bar",
"sourcePath": "ci/v2/reference-hooks/{G17.sh,lib/*}",
"consumedAs": [".siax/hooks/G17.sh"],
"compositionMechanism": "checkout-at-CI-runtime (pinned commit, SIAX_CI_TOKEN), not a vendored copy -- see .siax/hooks/README.md"
}
},
"templateRepo": "sax3l/siax-templates",
"templateRef": "a401880aa19a9bf99fbd796c0be481db2705fcaa",
"templateBranch": "main",
"templatePr": "sax3l/siax-templates#51 + follow-ups (PAY-35, ui-consumer lockfile, recovery wave, X-708 profiles, deps)",
"prStatus": "MERGED to main; re-pinned 2026-09-22: diff 7725498..a401880 contains ZERO changes under ci/v2/reference-hooks (verified via git diff --name-only) -- pin bump is behaviour-neutral for the G17 gate",
"pinnedAt": "2026-09-22T02:00:00Z",
"reSyncInstructions": "If siax-templates main moves past this commit: review the diff (`git -C <clone> log 384e35060b91ea78ec045e14af114abf5d5eb23b..main -- ci/v2/reference-hooks`), re-run this migration's local verification (real local registry + docker build from this repo's own Dockerfile + G17.sh end-to-end + tamper-rejection checks -- see the PR description for the exact commands used), then bump SIAX_TEMPLATES_PINNED_SHA in .gitea/workflows/release.yml and this file's templateRef together in one reviewed commit.",
"notes": "Supersedes 0pt's own ad hoc 'P007 pattern' release.yml (added 2026-09-14, audited 2026-09-15 and found to fail most of ADR-W16-003's bar: syft/cosign installed via unpinned-digest curl+checksums-refetch, no vulnerability scan of the built digest, cosign signed only the bare digest -- not a full in-toto predicate -- evidence committed back into this mutable repo at siax/production-evidence/*.json + siax/source-bundles/*.json instead of AUD0/ST0RE, no live-digest-equality proof). This pin replaces that ad hoc logic with sax3l/siax-templates' real, reviewed reference implementation; 0pt keeps only its own adapter values (image repo, license policy, key identity -- see G17.sh's own header) plus the pre-existing 0pt-specific signed-artifact wait/verify gate in cd.yml (kept, updated to cosign verify-attestation). G20 (live-digest-equality) deliberately NOT wired in this pass -- see .siax/hooks/README.md."
}
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# 0pt's G17 (Artifact security) hook adapter.
#
# 0pt's OWN release chain (`.gitea/workflows/release.yml`, added 2026-09-14
# as "P007 pattern") had a signing/SBOM chain -- but an audit (2026-09-15)
# found it failed most of the real ADR-W16-003 promotion bar it claimed to
# meet: syft/cosign installed via a `curl` + freshly-refetched checksums
# file (not a pinned digest), NO vulnerability scan of the built digest at
# all, `cosign sign` on the bare digest only (not source-SHA/build-identity/
# environment/SBOM-hash bound together), no in-toto/SLSA statement, and
# evidence committed straight back into this same mutable repo
# (`EVIDENCE_DIR="siax"` -> `siax/production-evidence/*.json`,
# `siax/source-bundles/*.json`, pushed with a SIAX_CI_TOKEN-authenticated
# `git push` on every release -- nine such commits already sit in this
# repo's history). This adapter, plus the rewritten `release.yml`, replaces
# that chain; the previous `siax/production-evidence/` and
# `siax/source-bundles/` trees are deleted in this same change (superseded,
# not left dangling -- see the PR description for exactly what a `git log`
# on those paths still shows for history).
#
# This adapter owns ONLY 0pt-specific values -- every ADR-W16-003 bar item
# (1-7: pinned-tool install, SBOM, blocking vuln scan, in-toto attestation,
# scoped-key signing, pubkey verification, AUD0/ST0RE evidence storage) is
# implemented ONCE in sax3l/siax-templates and executed here unmodified, per
# that reference file's own header: "This file is starting material a
# consumer repo copies to `.siax/hooks/G17.sh` and adapts -- it owns only
# the adapter ... `siax-templates` remains the sole owner of the G17
# CONTRACT and this reference implementation."
#
# --- Composition mechanism: checkout-at-CI-runtime, not a vendored copy ---
# `.gitea/workflows/release.yml` clones sax3l/siax-templates at a pinned
# commit into $SIAX_TEMPLATES_DIR before calling this file, authenticated
# with the SIAX_CI_TOKEN org secret 0pt's OWN release.yml already had wired
# for a DIFFERENT cross-repo write (the evidence-commit push this same
# change deletes) -- so the token and the auth idiom
# (`https://oauth2:${SIAX_CI_TOKEN}@git.siax.io/...`) are not new to this
# repo, only their use for a `git clone` of a sibling repo is. Picked over
# vendoring (the mechanism sax3l/st0re, sax3l/cl0ud and sax3l/api0's own
# G17 migrations used) because 0pt has ZERO prior vendored-copy convention
# anywhere in this repo (`grep -rl vendor .gitea/workflows/ .siax/` finds
# nothing, confirmed before writing this file) -- introducing one now, for
# this alone, would be a bigger repo-shape change than reusing the
# SIAX_CI_TOKEN pattern 0pt already has proof it can authenticate with.
# Matches sax3l/aud0 and sax3l/h0st's own G17 migrations, which
# independently made the same choice for the same reason. See
# `ci/shim/ci.yml` in siax-templates for why NOT a native
# `uses: sax3l/siax-templates/...@ref` reusable workflow (Gitea Actions
# clones a called repo ANONYMOUSLY, so this 404s on every private sax3l
# repo).
#
# GOTCHA LEARNED FROM sax3l/h0st's OWN MIGRATION (its
# `cl0ud-real-policy.yml` header, and independently from sax3l/aud0's):
# Gitea 1.26 on this fleet resolves ORG-level secrets (SIAX_CI_TOKEN is
# org-level) only for the FIRST job of a multi-job workflow file. That is
# exactly why `release.yml` (this file's caller) is a single-job workflow.
# 0pt does not wire a G20 (live-digest-equality) post-deploy step in this
# pass -- see `.siax/hooks/README.md` "G20 -- deliberately not wired yet"
# for why (no Coolify application provisioned for 0pt at all, and no
# `/version` endpoint for a live check to poll -- both genuine
# infrastructure gaps, not something this CI adapter can close).
#
# PIN STATUS (2026-09-15): sax3l/siax-templates PR #51
# (`feat/g17-signed-deploy-gate-adr-w16-003`) is MERGED to main, plus three
# real follow-up fixes already on top of that merge as of this pin --
# bf0c157/#56 "evaluate compound SPDX license expressions per SPDX
# semantics", 794546f/#57 "skip bare SPDX operator tokens emitted as their
# own license entry" (both exercised live below via G17_LICENSE_ALLOW
# against 0pt's real SBOM), and e1230a1/#59 "retry on observed-but-
# different sha" (a G20 fix; 0pt does not use G20 yet, but the pin still
# picks up main's current tip rather than an older commit, matching what a
# fresh migration reviews). Pinned at
# 384e35060b91ea78ec045e14af114abf5d5eb23b -- the exact ref lives in
# `.gitea/workflows/release.yml` (SIAX_TEMPLATES_PINNED_SHA, which is
# fetched and checked out DIRECTLY by SHA -- no branch is consulted, so an
# unrelated merge on siax-templates' main does not fail this gate; a pinned
# commit that no longer exists upstream does).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
: "${SIAX_TEMPLATES_DIR:?set by the calling workflow step to the path siax-templates was checked out to}"
REFERENCE_G17="$SIAX_TEMPLATES_DIR/ci/v2/reference-hooks/G17.sh"
[ -x "$REFERENCE_G17" ] || {
echo "0pt G17 adapter: FAIL: $REFERENCE_G17 not found/executable -- was the siax-templates checkout step run first, and did chmod +x survive the clone?" >&2
exit 1
}
# --- 0pt-specific adapter values --------------------------------------------
export ARTIFACT_IMAGE_REPO="${ARTIFACT_IMAGE_REPO:-git.cloud.siax.io/sax3l/0pt}"
export ARTIFACT_COMMIT_SHA="${ARTIFACT_COMMIT_SHA:?set by release.yml to the full 40-hex commit SHA this image was built from}"
export ARTIFACT_IMAGE_TAG="${ARTIFACT_IMAGE_TAG:?set by release.yml, e.g. sha-<12-hex> -- never :latest}"
export G17_ENVIRONMENT="production"
export G17_KEY_IDENTITY="siax-svc-g17/0pt/production"
# The reference script's own default for G17_COSIGN_PUBKEY assumes it was
# COPIED to .siax/hooks/G17.sh inside the consumer repo (relative path
# lands at the consumer's own root). This adapter instead runs the
# reference script from a SEPARATE checkout ($SIAX_TEMPLATES_DIR) -- that
# default would resolve inside siax-templates' own checkout, not 0pt's --
# so it must be pointed explicitly at 0pt's own committed key.
export G17_COSIGN_PUBKEY="$REPO_ROOT/cosign.pub"
# G17_COSIGN_KEY / G17_COSIGN_KEY_PASSWORD / AUD0_INGEST_TOKEN /
# AUD0_TENANT_ID are Infisical-injected repo secrets -- release.yml exports
# them from `secrets.*` before calling this file. This adapter deliberately
# never reads, echoes or forwards a raw secret value itself; it only relies
# on them already being present in the environment the reference script
# inherits.
# License policy -- measured 2026-09-15 against 0pt's OWN real signed
# artifact SBOM (280 components; real local-registry verification run
# performed during this migration, not copied from another repo's policy):
# * G17_LICENSE_ALLOW_UNKNOWN=1 -- 177/280 components declare no license
# field at all in their packed metadata (transitive npm packages --
# next/react/sharp's own dependency tree -- there is no fix for
# third-party metadata from this repo). Same opt-in st0re/aud0/h0st
# already record; visible in the attestation evidence, not silent.
# * G17_LICENSE_ALLOW below is the exact, minimal set of SPDX ids AND
# compound-expression branches 0pt's real SBOM needs once unknowns are
# allowed -- every id was individually present in the real scan
# output (`python3 -c "..." verify-work/e2e-out/sbom.cdx.json` run
# during this migration), not a broad copy of a sibling repo's list.
# GPL-2.0-only/GPL-2.0-or-later/GPL-3.0-or-later/LGPL-2.1-or-later/
# LGPL-3.0-or-later/Zlib/OpenSSL-Exception/NCSA cover alpine's own base
# packages this image's `RUN apk add --no-cache libc6-compat wget` (+
# the deps/sharp/build stages' own libc6-compat installs) pull in --
# the OS substrate node:22-alpine ships, not a dependency 0pt chose.
# NCSA specifically is gcompat's real declared license (a first real
# finding this migration's initial run caught and triaged: the OLD ad
# hoc gate never scanned licenses at all, so this had never been
# checked before). MIT/BSD-2-Clause/BSD-3-Clause/ISC/0BSD/Apache-2.0/
# CC0-1.0/BlueOak-1.0.0/MPL-2.0 cover the Next.js/React/sharp npm
# dependency tree's own real declared licenses, including the compound
# "MPL-2.0 AND MIT" and "GPL-2.0-or-later AND LGPL-2.1-or-later"
# expressions actually present in the SBOM (evaluated per real SPDX
# AND/OR semantics -- the two 2026-09-15 siax-templates fixes #56/#57
# this pin includes).
export G17_LICENSE_ALLOW_UNKNOWN="1"
export G17_LICENSE_ALLOW="${G17_LICENSE_ALLOW:-BlueOak-1.0.0,GPL-2.0-only,GPL-2.0-or-later,GPL-3.0-or-later,LGPL-2.1-or-later,LGPL-3.0-or-later,Zlib,OpenSSL-Exception,NCSA,MIT,BSD-2-Clause,BSD-3-Clause,ISC,0BSD,Apache-2.0,CC0-1.0,MPL-2.0}"
echo "0pt G17 adapter: delegating to $REFERENCE_G17 for artifact $ARTIFACT_IMAGE_REPO:$ARTIFACT_IMAGE_TAG"
exec bash "$REFERENCE_G17"
-117
View File
@@ -1,117 +0,0 @@
# `.siax/hooks/` -- 0pt's G17 (signed-deploy gate) adapter
## What this is
`G17.sh` in this directory is a thin **adapter**: it sets 0pt-specific
values (image repo, environment, key identity, license policy) and then
`exec`s the real, reviewed reference implementation checked out at CI
runtime from `sax3l/siax-templates` (`ci/v2/reference-hooks/G17.sh`). This
repo does not own, fork, or duplicate any of the ADR-W16-003 bar-item logic
itself -- see `G17.sh`'s own header for the full rationale and the audit
that motivated replacing 0pt's prior ad hoc gate.
## Composition mechanism: pinned-commit checkout at CI runtime
`.gitea/workflows/release.yml` clones `sax3l/siax-templates` at a pinned
commit into a scratch directory before invoking `.siax/hooks/G17.sh`,
authenticated with the `SIAX_CI_TOKEN` org secret. This was chosen over a
vendored copy under `.siax/hooks/vendor/` (the mechanism `st0re`, `cl0ud`
and `api0` used for their own G17 migrations) because 0pt has **no existing
vendored-copy convention anywhere in this repo** -- confirmed by searching
`.gitea/workflows/` and `.siax/` for any prior "vendor" pattern before
choosing -- while it **does** already reference `SIAX_CI_TOKEN` with the
exact same `git.siax.io` authenticated-clone idiom this change reuses (the
now-deleted evidence-commit-push step in the old `release.yml` used the
identical `x-access-token`/`oauth2` bearer pattern for a different
cross-repo write). Reusing a pattern this repo already has proof it can
authenticate with is a smaller, lower-risk change than introducing a new
vendoring convention for this alone. `sax3l/aud0` and `sax3l/h0st`'s own
G17 migrations independently reached the same conclusion for the same
reason.
## Why not a `uses:` reusable workflow
Gitea Actions on this fleet resolves a called repository **anonymously**
regardless of the caller's own auth, so `uses: sax3l/siax-templates/...@ref`
404s against any private `sax3l` repo. See `ci/shim/ci.yml` in
`siax-templates` for the fuller writeup. Checkout-at-runtime with an
authenticated clone is the only mechanism that works against a private
template repo on this Gitea instance.
## Pin status
Pinned to `sax3l/siax-templates` main at
`384e35060b91ea78ec045e14af114abf5d5eb23b` (2026-09-15) -- PR #51
(`feat/g17-signed-deploy-gate-adr-w16-003`) merged, plus follow-up fixes
#56/#57 (SPDX compound-license-expression evaluation, exercised live by
this adapter's own `G17_LICENSE_ALLOW` policy) and #59 (G20 retry logic;
not used by 0pt yet, see below). `.gitea/workflows/release.yml` fetches
and checks out `SIAX_TEMPLATES_PINNED_SHA` **directly by SHA** and refuses
to run if that exact commit cannot be checked out (removed or rewritten
upstream) -- bumping the pin is a reviewed, source-controlled diff to that
one line, never a silent re-resolution. It deliberately does **not**
compare the pin against `main`: that branch tip moves whenever anything
unrelated merges in `siax-templates`, and failing this chain on that said
nothing about whether the reviewed gate was safe (CD-03 / CI-RED-02). The
authoritative pin value is the one in `release.yml`.
## G20 (live-digest-equality) -- deliberately not wired yet
The reference implementation's bar item 8 proxy (`G20`, "does the running
service's own `/version` endpoint report the sha that was actually signed")
needs two things 0pt does not have today, confirmed live before writing
this file:
1. **A provisioned Coolify application.** `.gitea/workflows/cd.yml`'s own
guard step already fails closed on a literal `COOLIFY_APP_ID` placeholder
-- 0pt has never been deployed to the fleet.
2. **A `/version` (or equivalent) endpoint.** `app/api/` has `health/` and
`ready/` routes but no route reporting a build SHA, and the Dockerfile
declares no `ARG GIT_SHA`/`ARG BUILD_TIME` to bake one in.
Wiring G20 against nothing live would be decorative, not a real check --
the exact anti-pattern this whole migration replaces. Once both exist
(Simon provisions the Coolify app; a follow-up adds a `/api/version` route
and the Dockerfile `ARG`s, the same small change `h0st`'s own migration
made), copy `G20.sh` + its own single-job `.gitea/workflows/
g20-live-verify.yml` from `h0st` or `aud0`'s repo as the starting point --
**as its own workflow file**, not a second job appended to `cd.yml`: Gitea
1.26 on this fleet resolves org-level secrets (`SIAX_CI_TOKEN`,
`AUD0_INGEST_TOKEN`) only for the **first** job of a multi-job workflow
file (learned the hard way on `h0st`'s and `aud0`'s own migrations).
## Required secrets (not yet set on `sax3l/0pt` as of this migration)
Same prerequisite every prior migration needed before its `release.yml`
went green:
- `REGISTRY_USER` / `REGISTRY_TOKEN` -- `git.cloud.siax.io` push (already
required by the OLD release.yml too; unchanged).
- `COSIGN_PRIVATE_KEY` / `COSIGN_PASSWORD` -- the private half of this
repo's `cosign.pub` (UNCHANGED by this migration -- see below). Per the
old `release.yml`'s own comment this was "NOT YET SET on sax3l/0pt as of
this writing" before this PR either, so this remains exactly as
outstanding as it always was; this migration does not resolve it.
**This adapter's own local verification (see the PR description) used a
SEPARATE, throwaway cosign keypair generated and then deleted purely to
prove the mechanism end-to-end -- `cosign.pub` at this repo's root was
deliberately left untouched.** Overwriting a repo's real signing public
key from an agent session -- without knowing whether Simon already holds
a matching private key offline -- would silently orphan that key
forever (ECDSA key generation is not reversible); that decision belongs
to Simon, not to this migration. When the real keypair is provisioned
(fresh or pre-existing), commit its public half here and set the two
secrets together in the same change.
- `AUD0_INGEST_TOKEN` / `AUD0_TENANT_ID` -- new requirement vs. the old
gate (which never wrote to AUD0 at all). Needs a `siax-svc-g17`
workload-identity client provisioned in Zitadel, same open item every
prior migration recorded (see `G17-ADR-W16-003.md` "What this pass could
not test against production, and why" in `siax-templates`).
- `SIAX_CI_TOKEN` -- already exists on this repo (used previously for the
now-deleted evidence-commit push); reused here for the `siax-templates`
checkout.
Until these are set, `release.yml` fails closed on its first real run --
same honest behaviour as the gate it replaces, and the same prerequisite
`cl0ud`/`st0re`/`aud0`/`api0`/`h0st` each had before their own `release.yml`
went green.
-14
View File
@@ -1,14 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# product-blueprint — 0pt
schemaVersion: siax.product-blueprint/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
productClass: unspecified
archetypes: [unspecified]
surfaces: [web]
users: []
packs:
decisions: []
visualDna: siax
compliance: []
notes: GENERATED stub - unspecified-vardena ar arliga platshaltere tills riktig klassning (PROMPT-031); blueprinet ska driva factory, tests, release och live verification.
-9
View File
@@ -1,9 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# reuse-manifest — 0pt
schemaVersion: siax.reuse-manifest/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
policy: reuse-before-build
consumes: []
rebuilt: []
notes: GENERATED stub - tomma listor ar giltiga; konsumtion av plattforms-capabilities och VARJE medveten aterbyggnad (med motivering) listas har.
-7
View File
@@ -1,7 +0,0 @@
{
"schemaVersion": "siax.skills-lock/v1",
"repo": "0pt",
"generatedAt": "2026-09-23T21:12:27.075Z",
"skills": [],
"notes": "GENERATED stub - tomt ar den arliga 'inga skills deklarerade'-formen; fyll i via siax-templates."
}
-9
View File
@@ -1,9 +0,0 @@
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret.
# state-flow-manifest — 0pt
schemaVersion: siax.state-flow-manifest/v1
repo: 0pt
generatedAt: 2026-09-23T21:12:27.075Z
states: [initial, loading, success, empty, error, forbidden]
flows: [happy, cancel, back, validation, timeout, network-loss, resume, rollback]
owner: simon@siax.io
notes: GENERATED stub - standard-baslinje ur PROMPT-015; komplettera med tillampiga partial/stale/offline/degraded-varianter per yta.
-15
View File
@@ -1,15 +0,0 @@
{
"schemaVersion": "siax.template-pin/v1",
"repo": "0pt",
"profiles": ["base", "node", "ui", "deployable"],
"templateRepo": "sax3l/siax-templates",
"templateRef": "1feb97cd1d6da58bb4b420d860109285c47c741d",
"pinnedAt": "2026-09-14T23:10:00Z",
"pins": {
"mise": "task-entry i mise.toml (repot rot)",
"node": "22.13.0",
"pnpm": "9.15.9",
"lefthook": "1.x (aktiveras med npx lefthook install)"
},
"notes": "0pt = pnpm-repo (pnpm-lock.yaml v9). Python-tjansterna (services/api, services/scrapers) kor requirements.txt - uv-adoption ar DEFERRED tills lock skapats i separat migrering. Makefile = legacy-alias (thin wrappers), städas i separat PR med deleteBy."
}
-127
View File
@@ -1,127 +0,0 @@
# AGENTS.md — 0pt
> Operativ brief för AI-agenter (Claude Code / opencode / liknande) som arbetar
> i detta repo. Läs `CLAUDE.md` + `docs/compliance/` innan du gör ändringar.
## Vad det här är
0pt = SIAX Clouds Data OS: företags-/kontakt-/fordonsdata, enrichment,
entity-resolution, provenance per fält, adaptive router, orderflöde med
50/50-escrow. Next.js 16-webapp + FastAPI-engine + Python-skrapmotor i samma träd.
## Första steget
1. `git pull` (Gitea: `git.siax.io/sax3l/0pt` — det är canonical remote, ej GitHub).
2. Läs `CLAUDE.md` (kontraktet), `docs/architecture/ARCHITECTURE.md` (vad koden
verkligen gör, inkl. kända luckor S1–S12), `docs/compliance/` (status).
3. Kontrollera senaste CI-run (Gitea Actions) om du ska verifiera "grön".
## Viktiga förhållanden
- **Bypass-stacken** (`services/scrapers/*`, `waf_bypass.py`, data-stack-compose,
`adaptive_router.py`) är **behållen** — rör den inte. Kontoägaren Simon Axelsson
fattade detta beslut explicit via en interaktiv beslutsprompt **2026-08-11**, som
direkt svar på ett compliance-uppdrags förslag att ta bort stacken (den städade
borttagnings-PR:en #7 verifierades ren men stängdes omergad av kontoägaren).
Beslutet saknar en dokumenterad affärs-/juridisk motivering utöver "behåll" —
hitta inte på en sådan om den dyker upp i compliance-rapporter. Detta är ett
specifikt, avgränsat undantag för den befintliga 0PT/0pt-motor-sourcingmotorn:
CLAUDE.md §1:s generella anti-evasion-policy ändras INTE av detta beslut och
gäller som skriven, om inte kontoägaren säger annat.
- **Auth:** `/api/0pt/*` fail-closed i kod (Zitadel via middleware), men live-instansen
svarade 2026-08-10 anonymt 200 (lucka S1 — rotorsaksanalys ej klar). Påstå aldrig
att produktionen är skyddad innan en anonym request fått 401.
- **Tenant-id** får aldrig tas från request-body — idag gör handlers det (S2); nya
handlers ska läsa `x-0pt-verified-tenant`-headern.
- **Sanning:** repot har en stark anti-fabricering-konvention (docs korrigerar
README). Om något påstås vara byggt men inte finns: skriv "inte byggt", fabricera inte.
- **Skyddade sökvägar:** `PLAN/**` + `.gitea/workflows/**` + `.gitignore` —
ändringar går via PR. (Verifierat live 2026-09-20 via
`GET /repos/sax3l/0pt/branch_protections`.)
- **Fonts:** självhämtade via `@fontsource-variable/*` — lägg aldrig tillbaka
`next/font/google` eller `@vercel/*`.
- **Secrets:** aldrig i repo. Infisical (`vault.siax.io`), env via `process.env.*`.
- **Docker:** multi-stage, icke-root (`USER node`), ingen tsx i prod, healthcheck
mot `/api/health`.
## Toolchain-ägande (SIAX selected toolchain, 2026-09-14)
- `mise.toml` = task-entry (`setup/check/verify/build/release`). Ändra inte
betydelsen av dessa fem tasks. pnpm@9.15.9 följer packageManager.
- `lefthook.yml` = git hooks (pre-commit: typecheck+lint, pre-push: test).
Aktiveras med `npx lefthook install`.
- `Makefile` är legacy-alias ("thin wrappers", se dess header) — nya kommandon
läggs ALDRIG dit; mise är kanonisk entry. Makefile städas bort i separat PR
med deleteBy-registering.
- Pinning mot `sax3l/siax-templates`: `.siax/template-pin.json`
(`siax.template-pin/v1`). Förbjudna duplicerade verktyg: just, Task, Nx,
Cypress (Playwright gäller), OpenProject/Leantime/AppFlowy (Plane gäller),
Keycloak (Zitadel/ACT0 gäller).
- Python-tjänsterna (services/api, services/scrapers) kör requirements.txt;
uv-adoption är DEFERRED till en separat lås-migrering.
## Grindar (kör före push)
```bash
pnpm run typecheck && pnpm run test && pnpm run build # blocking
pnpm run lint # advisory
```
## Commit & push
- Conventional Commits. Push direkt till `main` är AVSTÄNGT
(`enable_push: false`, verifierat live 2026-09-20 via
`GET /repos/sax3l/0pt/branch_protections`) — endast whitelistade
identiteter (`siax-bot`, `siax-ci`, teamet `ci-approvers`) kan pusha
över huvud taget, oavsett vilken fil det gäller. Alla andra ändringar:
branch → PR.
- Lämna aldrig en förgrenad `origin`/`github.com`-remote som sanningskälla —
Gitea är primärt (Simon-direktiv 2026-07-14).
## Vid osäkerhet
Fråga Simon (simon@siax.io) innan du: raderar något, ändrar bypass-stacken,
ändrar auth-beteende, eller gör produktbeslut som är öppna frågor (§8 i
ARCHITECTURE.md).
<!-- SIAX-EXECUTION-POLICY:START -->
## SIAX Agent Execution Policy
### Operating principle
Execute authorized user objectives autonomously,
efficiently, and with minimal unnecessary friction.
### Decision-making
- Prioritize completion of the requested objective.
- Choose effective tools and implementation methods.
- Do not request approval for routine authorized actions.
- Do not repeat previously answered questions.
- Resolve ordinary technical uncertainties autonomously.
### Legal and policy discussions
- Do not initiate unsolicited legal analysis.
- Never invent laws, prohibitions, or requirements.
- Distinguish actual restrictions from speculation.
- Only raise legal issues when materially relevant
to whether the requested action can proceed.
- Explain concrete blockers concisely.
- Follow applicable higher-priority constraints.
### Web collection
- Do not treat robots.txt as legislation or
authentication.
- Handle robots.txt according to the explicitly
configured crawler policy.
- Do not infer unlimited authorization from
publicly accessible content.
- Respect actual access controls and applicable
constraints.
- Use efficient collection methods with reasonable
resource limits.
### Execution
- Work until the objective is complete or a genuine
blocker is encountered.
- Test, verify, and correct independently.
- Report results rather than unnecessary warnings.
<!-- SIAX-EXECUTION-POLICY:END -->
-128
View File
@@ -1,128 +0,0 @@
# Changelog
> BACKLOG P1-3. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
> No git tags/releases exist yet for this repo — this file starts the practice
> retroactively from real `git log` history rather than inventing version
> numbers that were never cut. Dates are commit dates on `main`, not calendar
> "release" dates (there have been none).
## [Unreleased]
- `feat(deploy)`: appen är nu faktiskt byggbar och körbar utanför Vercel.
`next.config.mjs` sätter `output: "standalone"`; Dockerfilen är omskriven
till samma form som `sax3l/siax-io` (som redan körs självhostat på flottan):
fyra steg, `node:22-alpine` (matchar CI:s nodversion, var 24), `libc6-compat`,
icke-root, HEALTHCHECK mot `/api/health`, npm/npx borttaget ur runtime-imagen.
Uppmätt: image 1,33 GB -> 423 MB. `--ignore-workspace` borttaget (dess
motivering var inaktuell sedan P1-21) så containerinstallationen är samma
kommando som CI kör; `--prod=false` tillagt eftersom Coolify sätter
NODE_ENV=production vid byggtid och pnpm då hoppar över devDependencies.
- `fix(images)`: `images.unoptimized: true` borttaget — det var v0-generatorns
standardvärde, inte ett val (`next.config.mjs` var orörd sedan initial commit).
Självhostad bildoptimering kräver att `sharp` faktiskt finns i runtime-imagen:
`next build` spårar den inte in i `.next/standalone`, och
`outputFileTracingIncludes` får Turbopack att panika på pnpms symlänkar. Sharp
levereras därför i ett eget npm-steg i Dockerfile, pinnat till versionen i
`pnpm-lock.yaml`. Verifierat i containern: `/_next/image` svarar 200 med
`image/avif` (1,46 MB PNG -> 6,7 kB).
- fix(security): response security headers are set by the app itself, in
`next.config.mjs` `headers()` — CSP (incl. `frame-ancestors 'none'`,
`base-uri 'self'`, `object-src 'none'`, `form-action 'self'`), HSTS,
X-Frame-Options `DENY`, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy — plus `poweredByHeader: false`. Regression-gated by
`tests/security/security-headers.test.ts` (blocking, runs in
`pnpm run test`) and re-checkable against a live origin with
`node scripts/siax-doctor.mjs --live`. (BACKLOG P0-13, P1-15)
Correcting this file: from 2026-08 until 2026-09-07 this bullet also
claimed the headers themselves, while `main` contained none — a repo-wide
grep for `content-security-policy|strict-transport-security|
x-frame-options` matched only CHANGELOG.md. The only thing setting HSTS on
0pt.app was Vercel's platform default (still observable on the 402
responses recorded in `evidence/operational-state.json`), which is not this
repo's and does not survive the move to Coolify/Traefik. The claim is true
as of this entry; the four claims it was bundled with are not, and are
tracked in the section below.
- fix(repo-honesty): `scripts/siax-doctor.mjs` validated that
`siax.repo.json` fields were PRESENT, never what they said — measured:
`operationalState: "running"` (false at the time; every declared host
answered 402/404) and `"down"` (true) both produced 40/40 and
byte-identical output. It now validates values (enums, hostname form),
cross-file consistency (declared prod hostnames must appear in this repo's
own deploy/contract config, and vice versa), and requires
`operationalState` to be backed by a dated probe record in
`evidence/operational-state.json` that agrees with it. Offline and
deterministic by default; `--live` (opt-in) probes the real hosts.
Gated by `tests/repo/siax-doctor.test.ts`, which also asserts the negative
cases against fixture trees. (BACKLOG P1-19, control AGENT-04, GOV-06)
- fix(health): `/api/health` reports real per-dependency status instead of a
hardcoded `ok: true`; new `/api/ready` readiness endpoint. (BACKLOG P0-3)
- docs/chore: repo-hygiene pass — schema-valid `siax.repo.json`/`siax.config.json`
(standard v2.0), ADRs, this CHANGELOG, `siax:doctor` script, devcontainer,
`docs/architecture/ARCHITECTURE.md` §5 "Ytor och paritet", CODEOWNERS,
stale-link cleanup. (BACKLOG P1-1, P1-2, P1-3, P1-19, P1-20, P1-21, P2-5, P3-5)
### Claimed here before 2026-09-07 but NOT implemented — status 2026-09-15
These were asserted by the earlier `fix(security)` bullet and were not true on
`main` at that time. Current state (measured against `main`, 2026-09-15):
- CI security gates now ENFORCE: `security.yml` and `nightly.yml` run Trivy
with `--exit-code 1` (landed via `ci/make-required-security-gate-real`,
2026-09-08). (BACKLOG P0-16 — CLOSED)
- gitleaks is still not run anywhere in this repo. (BACKLOG P0-19 — OPEN)
- Actions are still NOT pinned to commit SHAs (`actions/checkout@v4` etc.).
(BACKLOG P1-14 — OPEN)
- No SBOM and no license inventory are produced or uploaded as CI artifacts.
(OPEN; the `fix/wire-signed-digest-to-deploy` branch carries a prepared
release chain that needs its cosign/registry secrets before it can land.)
## 2026-08-11 — Evasion-stack decision provenance
- Attributed and dated the account-owner's keep-decision on `services/scrapers/*`
across `SECURITY.md`, `CONTRIBUTING.md`, `prompts/002-compliance-audit.md`,
`renovate.json`, `.gitea/workflows/security.yml`, `docs/slo.md`.
## 2026-08-09/10 — Golden Standard compliance pass (FAS 0–10)
- `docs/compliance/{INVENTORY,RELATIONS,OSS-INVENTORY,GOLDEN-STANDARD-REPORT,BACKLOG,SLUTRAPPORT}.md`
written; `siax.repo.json` classification recorded.
- `fix(security)`: `GET /api/orders` and `GET /api/admin/metrics` now require
auth (were live 200 anonymous — the routes sit outside `middleware.ts`'s
`/api/0pt/:path*` matcher).
- `fix(deploy)`: `siax.config.json` `deploy.target` flipped from `vercel` to
`coolify` (config-only; the live instance is still on Vercel until a
Coolify app is provisioned and DNS is cut over).
- `feat(contract)`: OpenAPI 3.1 SSOT added (`contracts/openapi.yaml`);
`@vercel/analytics` and `next/font/google` removed in favor of self-hosted
`@fontsource-variable/*` (AR-4).
- `ci(workflows)`: CD/security/nightly split under `.gitea/workflows/`.
- README/SECURITY/CONTRIBUTING/AGENTS + runbooks (deploy, incident,
backup-restore, oncall) + SLO doc added.
## 2026-08 — Auth + capability hardening ("Wave 2/3")
- `fix(auth)`: `AUTH_ENFORCE` default flipped to fail-closed on `/api/0pt/*`.
- Zitadel Bearer-token verification wired into `middleware.ts`
(`src/auth/zitadel.ts`, `src/auth/context.ts`).
- P0 capability layer built out: `0pt.query_source`, document
capture/extraction, Iceberg write-boundary, external writeback, dataset
quality flow docs.
- CI quality gate added (typecheck/lint/test), `.env.example` committed
(previously blocked by a blanket `.gitignore` `.env*` rule).
## 2026-07-13 — Scraper stack (kept, see BACKLOG P3-3 / SECURITY.md)
- `services/scrapers/*` (WAF-bypass, proxy rotation, ClickHouse pipeline) was
added on the Gitea line of history. This is the subject of the dated,
attributed account-owner decision referenced throughout this repo's
compliance docs — not reopened or altered by this changelog entry.
## Earlier
- Initial Next.js scaffold, order/catalog/portal UI, FastAPI engine
scaffold, i18n (EN/SV) and accessibility passes, lakehouse write-path
ownership ADR (`ADR-0030`, in the `n0d` repo).
[Unreleased]: https://git.siax.io/sax3l/0pt/compare/main...HEAD
-109
View File
@@ -1,109 +0,0 @@
# CLAUDE.md — 0pt (SIAX Technology AB)
> Read this first. It is the contract for how this project is built. Then read `docs/` in order.
## What 0pt is
**0pt — your data.** A Nordic/European data-services platform (`0pt.app`) where customers:
1. **Compose** a data job (markets, entity type, fields, enrichment, frequency, output) — or browse ready-made datasets.
2. Pay a **50 % deposit** to start; the remaining 50 % is held in **escrow** and released only on approval.
3. **Watch production live** (sourcing → extraction → structuring → enrichment → QA).
4. **Validate** the result with full freedom (table / map / schema / quality views) and deliver to any format/destination.
Owned by SIAX Technology AB. Feeds internal products too (Protosell/FiveCRM, VehIQ).
## Non-negotiable constraints (do not violate)
1. **Legal sourcing only.** The data engine uses, in strict priority: official APIs/bulk → authorized scraping of clearly public pages (respect `robots.txt` + rate limits) → licensed partner data → otherwise the field is delivered as **"not available / licensed only"**. We never invent values.
- **Do NOT build, keep, or extend**: WAF/bot-detection bypass (incl. FlareSolverr, "stealth" browsers, undetected-* used to defeat detection), fingerprint/proxy rotation to evade bans, scraping behind login walls, or automated fake-account creation (Apollo etc.).
- **Path corrected this pass:** no `src/workers/browser_worker.py` exists anywhere in this checkout (verified via full-repo search) — the engine's actual workers live under `services/api/workers/` (`adaptive_router.py`, `pipeline.py`, `queue.py`, `resolve.py`, `run.py`), none of which contain a FlareSolverr/bypass path today. The precedent for this concern is real and, **as of this GitHub<->Gitea unification merge, it is in this tree**: Gitea's `git.siax.io/sax3l/0pt` `main` independently accumulated a WAF-bypass/proxy-rotation/fake-identity scraper stack (`services/scrapers/*` — `curl_cffi`, `cloudscraper`, Tor circuit rotation) in three 2026-07-13 commits, and this branch inherits it because it branches from Gitea `main`. **The deletion PR (`feature/0pt-remove-noncompliant-scrapers`) is open, NOT merged** — an earlier version of this paragraph claimed it was "since removed", which was false. That removal is deliberately left to its own PR and was *not* folded into this merge; nothing in this merge resurrects, extends, or re-wires any of it. If new code (from either remote) reintroduces a bypass path anywhere, **remove or disable it** and replace with compliant fetching. This is a product-positioning + compliance requirement, not a preference. "Honest accuracy" is the whole moat.
2. **Postgres is the backbone.** One Postgres is the source of truth. Catalog, portal, NocoDB views, Metabase dashboards, workers, exports all read/write the *same* DB. A tool may only enter the stack if it (a) reads that Postgres and (b) sits behind the same auth.
3. **Provenance per field.** Every fact carries source, freshness, confidence (`bridge_provenance`). This drives the UI signature too.
4. **One tool per job.** Do not add overlapping tools. The approved stack is in `docs/02-architecture.md` and `docs/07-dependencies.md`. Anything not listed needs a documented reason.
5. **GDPR-native.** DPA, lawful basis, audit log, deletion-after-job. Customer DB access is *pull by the customer* via a read-only, time-boxed role they create — never us asking for admin, never raw creds stored in plaintext.
6. **Design is fixed.** Mono/editorial system (white `#fff`, text `#0a0a0a`, hairline `#e5e5e5`, radius 0, Inter + JetBrains Mono, single red `#e40014`). Exact tokens + motion in `docs/05-design-system.md`. No photography — imagery is rendered data (`DataArtifact`).
## Build on existing code
A working engine repo already exists. **Corrected this pass — the previous description of it was wrong on every specific:** the FastAPI orchestrator lives at `services/api/orchestrator/main.py` (not `src/orchestrator/main.py`) and registers **12** route handlers (not "25+"); the workers directory is `services/api/workers/` and contains `adaptive_router.py`, `pipeline.py`, `queue.py`, `resolve.py`, and `run.py` (not `browser_worker`/`ocr_worker`/`db` — none of those three files exist anywhere in this repo); and `services/api/sources/sources.yaml` currently covers **7** markets (`SE`, `NO`, `DK`, `FI`, `GB`, `NL`, plus `global`), not 20. `services/api/` holds 11 Python source files total (excluding `__pycache__`). **Reconcile with the code as it actually is, not this description's prior claims.** Where this documentation defines a target contract, migrate the existing code toward it. Flag conflicts instead of guessing.
**Two remotes claim to be this repo, and it's unresolved which is authoritative.** `origin` here is `github.com/sax3l/0pt`. A separate Gitea remote, `git.siax.io/sax3l/0pt`, shares at least part of the same history but has independently diverged (see README "Known issues" for specifics). Do not assume `origin` is the only copy of this codebase, and do not silently resolve the question of which is canonical — that is Simon's decision.
**Before trusting any status claim in this doc corpus, read README's "Known issues" section.** It covers the auth/tenant-scoping gap, the platform-client mock-only status, and the underlying fact that 0pt's quality-check/QA enforcement code was never built despite once being described as real. **Corrected this pass:** `docs/ICEBERG_WRITE_BOUNDARY.md`, `docs/11-lakehouse-write-plan.md`, and `docs/P0_QUERY_SOURCE_FLOW.md` are now all consistently corrected — each carries its own "fabricated, not built" callout and none asserts `quality-check-service.ts`/`quality-report-service.ts`/etc. as real or tested anywhere in its current body (verified this pass by reading all three in full). `docs/P0_DATASET_QUALITY_FLOW.md` remains the explicit target-spec replacement for what that stage should look like once built. Treat any *other* doc claiming the QA stage already exists as wrong.
## How to work
- Work in **dependency order** per `docs/09-build-plan.md`. Finish a phase to its acceptance criteria before the next.
- Small, reviewable commits. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`).
- Frontend = Next.js App Router + TS + Tailwind + shadcn/ui on the SIAX Deploy Platform. Backend = FastAPI + workers on a VPS (long jobs).
- Tech docs + code identifiers in **English**; all visible **UI copy in Swedish**.
- Never hardcode secrets — use env (`docs/08-infrastructure.md`).
- When unsure about a source's current access/licence terms, **verify before relying on it** and record it in the sourcing map; do not assume.
## Documentation index
| Doc | Purpose |
|---|---|
| `docs/01-product.md` | Vision, the three flows, order lifecycle, business model |
| `docs/02-architecture.md` | System architecture, stack decisions (in/out), hosting |
| `docs/03-data-model.md` | Postgres schema, provenance model, migrations |
| `docs/04-api.md` | FastAPI contract (canonical endpoints) |
| `docs/05-design-system.md` | Mono tokens, motion, visual system, signature |
| `docs/06-data-engine.md` | Workers, legal sourcing chain, pipeline, QA, fusion, sources.yaml |
| `docs/07-dependencies.md` | Repos/libraries to import (+ explicit exclusions) |
| `docs/08-infrastructure.md` | Hosting, docker-compose, env, security, CI/CD |
| `docs/09-build-plan.md` | Phased milestones with acceptance criteria |
| `docs/10-lakehouse-decision.md` | Pointer to an ecosystem-wide lakehouse ADR made outside this repo (Apache Iceberg = canonical SIAX lakehouse format, ADR-0030 in the `n0d` repo; 0pt = write-path owner). Does not change `docs/02`/`docs/03` |
| `docs/11-lakehouse-write-plan.md` | 0pt's own follow-up task list for the Iceberg write boundary — schema/event names for the implementation. **Corrected**: item 4 (quality rules) is explicitly labeled NOT BUILT, and the old fabricated `quality-check-service.ts`/`quality-report-service.ts` claim is quoted only inside its own "Correction" callout, never asserted as fact |
| `docs/12-edge-capability-api.md` | Edge-native TypeScript P0 capability API contract (`app/api/0pt/*`, `/api/screen`, `/api/ai/parse`) — distinct from `docs/04-api.md`'s FastAPI-engine contract; documents several confirmed always-fails-over-HTTP routes and the tenant_id/in-memory gaps |
| `docs/ICEBERG_WRITE_BOUNDARY.md` | Implementation record + "real vs. contract-only" verification status: dataset<->Iceberg-table mapping and source-rate-policy contracts, ST0RE lakehouse client, and the 2026-07-13 **live-verified** write path (real namespace/table/snapshot IDs). **Corrected**: its "Data quality: not yet built" section explicitly quotes and retracts the old fabricated claim rather than repeating it as fact |
| `docs/P0_QUERY_SOURCE_FLOW.md` | `0pt.query_source` bounded source-query flow — **corrected**: no longer claims `quality-check-service.ts` exists, now points at the real precedent (`iceberg-write-service.ts`) instead |
| `docs/P0_DATASET_QUALITY_FLOW.md` | An explicit **target-spec-only, not-yet-built** replacement for the fabricated quality-check/QA claims — read it as "should", never "does" |
**Corrected:** this table previously listed `docs/design/0pt-v0-build-brief.md` and `docs/design/0pt-design-coverage-and-visuals.md` as if they existed. They do not — confirmed via a full-repo search, including after this unification merge; there is no `docs/design/` directory at all. No `docs/` design-brief content is fabricated here to fill the gap. Either write those two docs or stop referencing them; until one of those happens, treat any prior claim that they exist as false.
## Definition of done (every screen, before real data)
Mono chrome · correct scroll-reveal motion · representative placeholder content · **loading + empty + error** states · mobile layout · provenance signature where facts appear · red used ≤ once · visible focus ring · `prefers-reduced-motion` respected.
<!-- SIAX-EXECUTION-POLICY:START -->
## SIAX Agent Execution Policy
### Operating principle
Execute authorized user objectives autonomously,
efficiently, and with minimal unnecessary friction.
### Decision-making
- Prioritize completion of the requested objective.
- Choose effective tools and implementation methods.
- Do not request approval for routine authorized actions.
- Do not repeat previously answered questions.
- Resolve ordinary technical uncertainties autonomously.
### Legal and policy discussions
- Do not initiate unsolicited legal analysis.
- Never invent laws, prohibitions, or requirements.
- Distinguish actual restrictions from speculation.
- Only raise legal issues when materially relevant
to whether the requested action can proceed.
- Explain concrete blockers concisely.
- Follow applicable higher-priority constraints.
### Web collection
- Do not treat robots.txt as legislation or
authentication.
- Handle robots.txt according to the explicitly
configured crawler policy.
- Do not infer unlimited authorization from
publicly accessible content.
- Respect actual access controls and applicable
constraints.
- Use efficient collection methods with reasonable
resource limits.
### Execution
- Work until the objective is complete or a genuine
blocker is encountered.
- Test, verify, and correct independently.
- Report results rather than unnecessary warnings.
<!-- SIAX-EXECUTION-POLICY:END -->
-8
View File
@@ -1,8 +0,0 @@
# BACKLOG P1-15 (CI-14): required reviewers for Gitea's branch-protection
# "required approvals" setting to have anyone to assign. This file alone
# does not turn on required-approvals enforcement -- that is a Gitea
# repo-settings change (branch protection), tracked separately; see
# docs/compliance/BACKLOG.md P1-15 ("required_approvals: 0").
#
# Gitea CODEOWNERS syntax matches GitHub's: path pattern, then owner(s).
* @sax3l
-58
View File
@@ -1,58 +0,0 @@
# Contributing — 0pt
> 0pt (SIAX Cloud Data OS) är proprietär mjukvara från SIAX Technology AB.
> Interna bidrag följer konventionerna nedan. Externt: kontakta
> `security@siax.io` / `simon@siax.io` innan bidrag.
## Repo-ryggrad
- **Kategori:** platform (K1) · **Subkategori:** data-os · **SSOT:**
[`siax.repo.json`](siax.repo.json) + [`siax.config.json`](siax.config.json)
- **Kontrakt:** [`contracts/openapi.yaml`](contracts/openapi.yaml) — alla
API-ändringar börjar här (SSOT), sedan routehandler.
- **Dokumentation:** `CLAUDE.md` först, sedan `docs/` och `docs/compliance/`.
Om kod och docs avviker: skriv avvikelsen, fabricera inte.
## Hårda regler (bryt aldrig)
1. **AR-1** — radera aldrig befintlig funktionalitet/endpoint. Bygg färdigt.
Undantag: `services/scrapers/*` + `services/infra/docker-compose.data-stack.yml`
är **medvetet behållna per kontoägarens daterade beslut** (Simon Axelsson,
2026-08-11 — se SECURITY.md/AGENTS.md) — rör dem inte.
2. **AR-2** — rör aldrig frysta kundrepon (contentroom, v0-datahub, prt-*, …).
3. **AR-4** — inga betald-SaaS-beroenden; inga Vercel/GCP/Azure/Google Fonts/
`next/font/google`; Cloudflare endast inom pågående migrering.
4. **AR-5** — bevis, inte påståenden. Märk VERIFIERAT/ANTAGANDE/EJ VERIFIERAT.
5. **AR-6** — aldrig hemligheter i repo (Infisical). Inga `NEXT_PUBLIC_*`-hemligheter.
6. **AR-8** — beroenden pekar bara nedåt (K1; aldrig kundrepon).
7. Tenant-id bunden till sessionen — aldrig litat på från request-body.
## Arbetsflöde
1. Branch från `main`: `git switch -c fix/my-change` (conventional prefix).
2. `pnpm install --frozen-lockfile` (eller `--no-frozen-lockfile` om lockfilen
medvetet uppdateras).
3. Grindar innan push:
```bash
pnpm run typecheck # BLOCKERANDE (src/ + tests/)
pnpm run test # BLOCKERANDE (vitest)
pnpm run lint # rådgivande (legacy-noise finns)
pnpm run build # BLOCKERANDE (next build)
```
4. Conventional Commits: `feat:` / `fix:` / `docs:` / `chore:` / `ci:` / `refactor:`.
5. **`.gitea/workflows/**` och `.gitignore` är skyddade på `main`** — ändringar
där går via PR (push direkt avvisas av pre-receive-hook).
6. Öppna PR mot `main`; CI måste vara grön innan merge.
7. Commit-meddelanden på engelska; UI-copy på svenska.
## Testkonventioner
- Enheter + capability-tester: `tests/{auth,p0,validation}/`.
- Real-infra-tester (`*.realinfra.test.ts`) skippar ärligt när ST0RE/N0D inte nås.
- Lägg aldrig till en test som fabricerar påståenden om kod som inte finns.
## Merge & release
- Merge via PR till `main` (skyddad). CD: `.gitea/workflows/cd.yml` triggar
Coolify-redeploy — fails medvetet tills en Coolify-app provisionerats och
`COOLIFY_APP_ID`/`COOLIFY_TOKEN` satts.
-170
View File
@@ -1,170 +0,0 @@
# =============================================================================
# 0pt web console — Dockerfile för Coolify (SIAX Cloud)
# =============================================================================
# Referensimplementation: sax3l/siax-io, som redan körs självhostat på flottan
# (Hetzner + Coolify + Traefik). Samma form återanvänds här i stället för att
# uppfinna ett eget mönster: flerstegsbygge, node:22-alpine, libc6-compat för
# sharp, icke-root, HEALTHCHECK, och npm/npx borttaget ur runtime-imagen.
#
# Den deployerbara tjänsten i det här repot är Next.js 16-appen i repo-roten.
# Liveness: GET /api/health (svarar alltid 200 så länge processen lever).
# Readiness: GET /api/ready (503 tills DATABASE_URL pekar på en nåbar Postgres)
# — peka Coolifys readiness-probe dit, INTE HEALTHCHECK nedan.
#
# VAD SOM ÄNDRADES MOT FÖREGÅENDE VERSION AV DEN HÄR FILEN, och varför:
#
# 1. next.config.mjs sätter nu `output: "standalone"`. Runtime-steget bär
# därför .next/standalone (server.js + spårade moduler) i stället för hela
# node_modules. Mätt på det här repot: den gamla imagen var 1,33 GB på disk
# / 315 MB komprimerat innehåll.
# 2. node:24-alpine -> node:22-alpine. Båda CI-workflowsen (.gitea/ och
# .github/workflows/ci.yml) kör `next build` på node 22; siax-io kör 22 i
# produktion. Att bygga och köra på samma major som grinden testar tar bort
# en version-skew utan uppsida.
# 3. `--ignore-workspace` borttaget. Kommentaren som motiverade flaggan
# ("pnpm-workspace.yaml ... carries only an `allowBuilds:` block and NO
# `packages:` field") är inaktuell: filen har ett giltigt `packages:`-fält
# och packages/schemas är en riktig workspace-medlem med egen package.json
# sedan BACKLOG P1-21. Utan flaggan blir installationen här BIT FÖR BIT
# samma kommando som CI kör (`pnpm install --frozen-lockfile`).
# 4. `--prod=false` tillagt. Coolify injicerar NODE_ENV=production vid byggtid,
# vilket får pnpm att hoppa över devDependencies (typescript, tailwindcss)
# som `next build` kräver. Samma fallgrop dokumenterad i siax-io:s
# Dockerfile för npm's `--include=dev`.
# 5. Eget `sharp`-steg, se nedan.
# 6. npm/npx strippat ur runtime-imagen (SIO-019-mönstret från siax-io).
# =============================================================================
# ---- beroenden ---------------------------------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# sharps förbyggda binär och libvips kräver libc-kompatibilitet på Alpine.
RUN apk add --no-cache libc6-compat
# corepack-fetch-flaken (server6-byggen, ECONNRESET mot registry.npmjs.org):
# retry-loop 8 försök à 15 s — samma mönster som c0nf0rm #56 / k0b.
RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --activate && break || { echo "corepack retry $i/8"; sleep 15; }; done
# Manifesten först, för lagercache. pnpm-workspace.yaml OCH
# packages/schemas/package.json måste med: pnpm-lock.yaml har två importers
# (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens
# manifest saknas på disk.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
# Every workspace importer in pnpm-lock.yaml must have its manifest on disk,
# otherwise --frozen-lockfile refuses to install. Keep this list in sync with
# `packages/*/package.json`.
COPY packages/schemas/package.json ./packages/schemas/package.json
COPY packages/decision/package.json ./packages/decision/package.json
COPY packages/siax-access/package.json ./packages/siax-access/package.json
# --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript
# så fort byggmiljön råkar ha NODE_ENV=production satt.
RUN pnpm install --frozen-lockfile --prod=false
# ---- sharp (bildoptimering) --------------------------------------------------
# Nexts bildoptimerare laddar `sharp` med en runtime-`require`, så `next build`
# spårar den INTE in i .next/standalone. Verifierat i det här repot: ett bygge
# med enbart `output: "standalone"` gav ett .next/standalone/node_modules som
# bara innehöll next/, react/ och react-dom/.
#
# `outputFileTracingIncludes` går inte att använda som lösning: globben
# "./node_modules/@img/**/*" får Turbopack att panika på pnpms katalogsymlänkar
# i node_modules/.pnpm ("Is a directory (os error 21)") och bygget faller med
# exit 1. Reproducerat, inte antaget.
#
# Därför ett eget steg med npm i stället för pnpm: npm lägger ett PLATT,
# symlänkfritt node_modules, vilket är precis vad runtime-steget kan kopiera rakt
# av. Versionen läses ur pnpm-lock.yaml så den aldrig kan glida ifrån vad resten
# av bygget löste ut, och steget faller hårt om utläsningen inte ger något.
# Eftersom det körs i en Alpine-image blir det musl-binärerna (@img/*-linuxmusl-*)
# som hamnar där, inte värdmaskinens.
FROM node:22-alpine AS sharp
WORKDIR /sharp
RUN apk add --no-cache libc6-compat
COPY pnpm-lock.yaml ./
RUN set -eu; \
SHARP_VERSION="$(sed -n 's/^ sharp@\([0-9][^(:]*\).*/\1/p' pnpm-lock.yaml | sort -u | head -1)"; \
if [ -z "$SHARP_VERSION" ]; then \
echo "FEL: hittade ingen sharp-version i pnpm-lock.yaml — vägrar bygga en image vars bildoptimering tyst skulle vara trasig." >&2; \
exit 1; \
fi; \
echo "sharp pinnad från pnpm-lock.yaml: ${SHARP_VERSION}"; \
npm install --no-save --no-audit --no-fund "sharp@${SHARP_VERSION}"; \
node -e "require('sharp'); console.log('sharp laddar OK i byggsteget')"
# ---- bygge -------------------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app
RUN apk add --no-cache libc6-compat
# corepack-fetch-flaken (server6-byggen, ECONNRESET mot registry.npmjs.org):
# retry-loop 8 försök à 15 s — samma mönster som c0nf0rm #56 / k0b.
RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --activate && break || { echo "corepack retry $i/8"; sleep 15; }; done
# Endast rot-projektets node_modules kopieras. packages/schemas har noll
# beroenden (den paketerar bara JSON Schema-filer som konsumeras via sökväg),
# så pnpm skapar aldrig något packages/schemas/node_modules att kopiera —
# verifierat med `ls` efter en frozen install.
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Måste vara production. devDependencies behövs inte som NODE_ENV här — de ligger
# redan i node_modules från deps-steget. NODE_ENV=development skulle dessutom
# dra in Reacts utvecklingsruntime under prerendering (se siax-io:s Dockerfile,
# där det är uppmätt åt båda hållen).
ENV NODE_ENV=production
# Bygget får inte kräva riktiga secrets: lib/db.ts och lib/stripe.ts är lata och
# env-skyddade, och middleware.ts:s startgrind (assertAuthConfigSafe) klagar bara
# om AUTH_ENFORCE uttryckligen sätts till "false" i produktion. Verifierat: hela
# bygget går igenom utan en enda env-variabel satt.
RUN pnpm run build
# ---- runtime -----------------------------------------------------------------
FROM node:22-alpine AS runtime
WORKDIR /app
# `apk upgrade` hämtar Alpines senaste patchade paket (t.ex. libssl3/libcrypto3 —
# bastaggen släpar efter uppströms säkerhetsfixar) utan att röra den pinnade
# Node-versionen. wget behövs för HEALTHCHECK: bara node:*-alpine har varken
# curl eller wget.
RUN apk add --no-cache libc6-compat wget && apk upgrade --no-cache
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# standalone-utdatan kopierar varken public/ eller .next/static — de måste med
# explicit, annars serveras appen utan statiska assets.
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
# sharp läggs BREDVID standalone-utdatans egna node_modules (next/react/react-dom).
# Docker slår ihop katalogerna, så inget skrivs över. `require('sharp')` från
# next/dist/... löses ut genom att vandra uppåt till /app/node_modules/sharp.
COPY --from=sharp --chown=node:node /sharp/node_modules ./node_modules
# Bas-imagen node:22-alpine skeppar npms egen globala installation
# (/usr/local/lib/node_modules/npm) med ett eget beroendeträd som regelbundet
# bär kända HIGH/CRITICAL-CVE:er, helt utan koppling till den här appens kod.
# Runtime-steget kör `node server.js` — varken RUN, CMD eller HEALTHCHECK här
# anropar npm eller npx — så trädet kan tas bort i stället för att accepteras.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx /usr/local/share/man/man1/npm*
USER node
EXPOSE 3000
# LIVENESS. /api/health svarar 200 så länge processen kan svara — rätt signal för
# "starta om containern". Peka INTE hit en readiness-probe: /api/ready svarar 503
# tills DATABASE_URL är satt och Postgres är nåbar, och en HEALTHCHECK mot den
# skulle få Coolify att döda en container som fungerar precis som avsett.
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1
CMD ["node", "server.js"]
-315
View File
@@ -1,315 +0,0 @@
# IMPLEMENTATION_REPORT.md — P010 Data-OS completion (sax3l/0pt)
**Branch:** `feat/p010-data-os-completion` (renamed mid-pass from
`feat/p010-source-registry-persistence` once scope grew beyond persistence
alone).
**Base:** `origin/main` @ `f8bb0d5` (verified in sync via `git fetch --prune`
before starting; `origin` = `git.siax.io/sax3l/0pt`, the canonical remote per
`CLAUDE.md` — the GitHub copy was not touched).
**Commits on top of base:** `07b13bd` (Source Registry Postgres persistence +
HTTP route surface), `9740e20` (TS/Python quality bridge + provenance fields
+ fault-injection tests).
**Report date:** 2026-09-01. Written after a from-scratch verification pass
(fresh clone, fresh `pnpm install`, fresh Python venv — not a rerun of the
working tree that built the branch), so the numbers below are reproducible,
not just remembered.
This report follows `BEFORE_STATE.md`'s classification scale: `VERIFIED` /
`IMPLEMENTED_NOT_VERIFIED` / `DOCUMENTED_ONLY` / `STALE` / `BROKEN` /
`UNKNOWN`. Read `BEFORE_STATE.md` first — this report only describes the
delta from it.
---
## 1. What actually changed, mapped to BEFORE_STATE.md's 5 gaps
### §1 Source Registry persistence — `VERIFIED` closed
- New `src/repositories/*` layer: `SourceRepository` /
`SourceRegistryRepository` interfaces, each with an `in-memory` and a
`postgres` implementation (`postgres-source-repository.ts`,
`postgres-source-registry-repository.ts`). `src/repositories/db.ts` is a
thin pooled-client wrapper honoring `resolveStoreMode`
(`src/platform/dependency-mode.ts`) — the same guard BEFORE_STATE.md noted
as already blocking production boot without a DB; it now has a Postgres
implementation to fall through to instead of only throwing.
- `sql/002_source_registry.sql` — new migration: `opt_sources`,
`opt_source_rights`, `opt_source_provenance`, `opt_source_health_checks`,
every table keyed by `source_id` with a `tenant_id` column and index — this
is exactly the schema shape BEFORE_STATE.md §1 derived as "what Postgres
migration would require" but said did not exist. It now exists and is
applied.
- `src/services/source-service.ts` and `source-registry-service.ts`
rewritten to take a repository (constructor-injected), closing the
**per-instance-Map** defect BEFORE_STATE.md flagged as worse than plain
in-memory: every route module now shares one repository-backed instance
(`src/services/source-registry.ts` is the new shared-singleton wiring),
not a fresh `Map` per `new SourceService()`.
- New route surface closing BEFORE_STATE.md's "effectively zero HTTP routes"
finding: `app/api/0pt/sources/route.ts` (create/list — did not exist
before), `[sourceId]/route.ts` (get/describe — did not exist before),
`[sourceId]/rights`, `[sourceId]/provenance`, `[sourceId]/health`,
`[sourceId]/freshness` (all new). `[sourceId]/query` and
`rate-policies/*` (pre-existing routes) were touched only to repoint them
at the shared singleton instead of constructing their own service.
- `tests/p0/source-registry-postgres.realinfra.test.ts` (340 lines, 7
tests) — a **real-Postgres** suite, not a mock. It self-skips honestly
(not fabricating pass) when no reachable Postgres is configured for the
run; see §2 (Testing) below for what this pass actually observed.
**What is still NOT true, stated plainly:** in-memory mode is still the
default when no `DATABASE_URL`/Postgres is configured — this pass added a
real persistence *option*, it did not remove the in-memory path (removing it
would break every existing unit test and any deployment without Postgres
wired yet). `resolveStoreMode` still hard-blocks production boot without a
real DB configured, which is correct behavior, not a residual bug.
### §2 TS/Python split-brain — `VERIFIED` bridged (additive, advisory)
- **New route surface**, `app/api/0pt/quality/*`: `rules` (list/create),
`rules/[ruleId]` (get/update), `runs` (list/create), `runs/[runId]` (get),
`issues` (list), and `POST /api/0pt/quality/bridge-run` — the entry point
the Python side calls. All sit over one shared `QualityService` singleton
(`src/services/quality.ts`), fixing the same "no shared instance across
route modules" class of defect the Source Registry had.
- `src/quality/quality-bridge.ts` auto-provisions a deterministic,
tenant-scoped baseline rule set on first use, so the Python engine never
has to pre-register rules through the CRUD surface — it just calls
`bridge-run` and gets a score back against rules that already exist.
- **Python side**, `services/api/workers/quality_bridge.py`: an HTTP client
to the route above.
- **Feature-flagged off by default**: `OPT_QUALITY_BRIDGE_ENABLED` unset
or falsy → zero network calls, returns immediately (`status:
"disabled"`). Verified in code (`_enabled()`, `services/api/workers/
quality_bridge.py:59-60`) and in tests
(`test_disabled_by_default_makes_zero_network_calls`).
- **Fails open, unconditionally.** Every failure mode — bridge URL unset,
connection refused, timeout, non-2xx, malformed JSON — is caught inside
the module and reported back as a `BridgeResult` with a `status` field
(`disabled` / `not_configured` / `unavailable` / `ok`), never raised into
`run_job`. `pipeline.py`'s actual delivery decision
(`delivered = [r for r in deduped if r["confidence"] >= threshold]`,
still the one line BEFORE_STATE.md quoted) is **unchanged** by any
bridge outcome — success or failure. Verified by
`test_bridge_disabled_delivers_identically_to_bridge_enabled_and_failing`,
which asserts the delivered row set is byte-identical between the two
paths.
- **Advisory only, and this is a real constraint, not an excuse:** true
per-row gating against the TS `ExpectationEngine` is not implementable
today because `ExpectationEngine` returns dataset-level pass/fail counts
and sample rows, not a per-row boolean array keyed to the Python
pipeline's record identity. Building that would mean changing
`ExpectationEngine`'s contract — out of scope for this pass, documented
rather than faked with a fabricated per-row mapping.
- **Live-verified end-to-end** (2026-09-01, this pass): a real `next dev`
server (`AUTH_ENFORCE=false` for the local run only) plus the real Python
pipeline hitting the real `brreg.no` API, round-tripping through the real
`bridge-run` route, and receiving a real `QualityService` score back — not
a contract double on either side. This was a manual verification run
during development; it is not itself a repeatable automated test (the
pytest suite below covers the same code paths against `respx`-mocked
bridge responses, which *is* repeatable and is what CI would run).
**What is still NOT true, stated plainly:** the bridge is **not enabled in
production and this pass does not enable it.** `OPT_QUALITY_BRIDGE_ENABLED`
stays unset/`false` as shipped. Turning it on is a separate, deliberate
decision — see §4 "Activating the bridge" below. Until that decision is
made, the two runtimes' QA logic remains exactly as split as BEFORE_STATE.md
described: Python's one-line confidence filter is what actually decides
delivery; the TS `QualityService`/`ExpectationEngine` only sees a
*copy* of the same data, advisory-only, and only when the flag is on.
### §3 Provenance fields — `VERIFIED` extended (Python side)
`services/api/sources/connectors.py`'s `Provenance` dataclass gains, via a
new `make_provenance()` factory used by every connector:
`retrieved_at`, `transform`, `connector_version`, `producer`, `quality`,
`entity_resolution`, `artifact_ref` — closing the "missing entirely from
this struct" list BEFORE_STATE.md §3 itemized (retrieval-method-at-field-
level remains only partially addressed: `connector_version` +`producer`
identify *what ran*, not a full method taxonomy like the TS
`SourceProvenance.method` enum).
- `resolve.py` stamps `entity_resolution` on **merged** records' provenance
only (never on unmerged ones — verified by
`test_unmerged_record_keeps_entity_resolution_none`), tracking
`agreed_sources`/`merged_from` in a structured field rather than the loose
attribute BEFORE_STATE.md found.
- `pipeline.py` stamps `artifact_ref` at delivery time and `quality` once
(if) the bridge call above succeeds.
**What is still NOT true, stated plainly:** `bridge_provenance` as a single
named construct **still does not exist** anywhere in this repo, and this
pass does not introduce that name. What exists is still two structs — the
Python `Provenance` dataclass (now richer) and the TS `SourceProvenance`
type (per-source, unchanged this pass) — reconciled only at the one new
seam described in §2 (the bridge-run response, which is advisory and
optional). This pass closes real gaps in the Python struct; it does not
unify the two runtimes' provenance models into one schema. Nothing here
persists past the in-process job response either — `pipeline.py` still has
no DB write of its own (Postgres persistence added this pass is scoped to
the Source Registry, not to order/provenance records).
### §4 Failure-mode tests — `VERIFIED` extended, with an honest residual
New pytest + respx suite, `services/api/tests/`:
| File | Covers |
|---|---|
| `test_resolve_determinism.py` | dedup-determinism under shuffled/interleaved orderings, exhaustive permutations of a mutually-similar set, duplicate-input collapsing without double-counting agreement, out-of-order multi-market arrival, entity-resolution provenance stamping — **and** an explicit, passing test of the one case BEFORE_STATE.md flagged as a real, plausible order-sensitivity risk: a non-transitive-similarity triangle in the greedy clusterer genuinely *can* cluster differently by input order. That test (`test_a_non_transitive_triangle_can_cluster_differently_by_order`) asserts the order-sensitivity, it does not paper over it — `resolve.py`'s greedy-merge algorithm was not rewritten to remove this property, since doing so is an algorithm change out of this pass's scope. |
| `test_pipeline_fault_injection.py` | schema-evolution (upstream adding/removing/reshaping fields via respx-mocked responses through the real `brreg` connector + `pipeline.py`), restart/connector-crash resilience (a connector exception, including one raised by `respx` itself, never crashes `run_job`; every connector failing delivers zero rows, not a crash), backfill-replay (replaying the same order config is byte-identical — this is the property that actually holds given there is still no persistence layer for orders, not a fabricated "dedup store" test). |
| `test_quality_bridge.py` | all bridge states above (disabled / not-configured / unavailable / timeout / non-2xx / malformed / connection-refused / success), plus the "wired into the pipeline" tests proving delivery is unaffected either way. |
| `test_connectors_provenance.py` | the `make_provenance()` factory populates every new field honestly and leaves the rest `None` rather than fabricating values; a real parsing path (`brreg`) produces a fully populated provenance record. |
| `test_known_gaps.py` | **2 explicit, documented skips** — not silently dropped, not faked: CDC-interruption and true delivery-store backfill idempotency are not implementable against this codebase, because **no CDC pipeline and no persistence layer for orders/deliveries exists anywhere in `services/api`.** Testing either would require building the infrastructure the test claims to fail-inject against, which is a different, larger piece of work than this pass. |
Net: of BEFORE_STATE.md §4's four **entirely absent** categories
(schema-evolution, CDC-interruption, out-of-order, dedup-determinism), three
are now genuinely covered (schema-evolution, out-of-order,
dedup-determinism) and one (CDC-interruption) is honestly documented as not
implementable rather than covered. Tenant-isolation (already the
best-covered category) is untouched by this pass. Quality-gate coverage
gains the bridge-interaction tests above but the production Python filter
itself (`delivered = [...]`) still has no line-level unit test isolated
from an end-to-end pipeline run — this pass tests it via the fault-injection
and bridge suites' end-to-end assertions, not a standalone unit test of that
one line.
### §5 1P/ST0RE/N0D/AUD0/CL0UD integration — unchanged this pass
Not touched. BEFORE_STATE.md's classification stands: ST0RE + N0D
real-contract (ST0RE live-verified once, historically); AUD0 real-contract
client but unused by the live Python pipeline (still true — `quality_bridge.py`
does not emit AUD0 events, and neither does the rest of `services/api`);
CL0UD real-contract but not platform-wide-enforced; 1P documented-only.
---
## 2. Testing — from-scratch verification (this pass, not a rerun)
To avoid reporting numbers from the working tree that built the branch, this
verification was run against a **fresh `git clone`** of the pushed branch
(`feat/p010-data-os-completion` @ `9740e20`) in an isolated directory, with a
fresh `pnpm install --frozen-lockfile` and a fresh Python venv — no reuse of
node_modules, .next cache, or an existing venv from development.
| Check | Result | Note |
|---|---|---|
| `pnpm install --frozen-lockfile` | OK | lockfile v9, no resolution drift |
| `pnpm run typecheck` (`tsc -p tsconfig.check.json`, **CI-blocking**) | **0 errors** | Clean on the pristine clone. The working directory this branch was developed in separately contains 5 **untracked, uncommitted** paths (`src/fleet/`, `src/graph/`, `src/metrics/`, `src/projections/`, `src/features/feature-registry.ts`) that were already present and flagged, unaudited, in `BEFORE_STATE.md` before this pass started. They are not part of any commit on this branch or on `main` — confirmed by `git diff --stat origin/main..feat/p010-data-os-completion` touching none of them, and by their total absence from the fresh clone. Running `pnpm typecheck` inside the *original, non-clean* working directory does surface 14 TS errors from those 5 stray local files; that is local working-tree litter, not something CI or a fresh clone will ever see, and this pass does not touch, fix, or commit any of those files. |
| `pnpm run test` (vitest, **CI-blocking**) | **256/256 passed, 18 skipped** (34 files passed, 3 files skipped) | Skips are the pre-existing `.realinfra`/`.integration` suites that self-skip honestly when no live ST0RE/OpenSearch/ClickHouse is reachable from this environment (`[realinfra] ST0RE ... not reachable this run (HTTP 401) -- skipping honestly, not fabricating success` — actual stderr from this run). 249 of the 256 predate this branch; 7 are new (`tests/p0/quality-bridge.test.ts`). |
| `pnpm run lint` (eslint, **advisory in both CI workflows, `continue-on-error`**) | 12 errors / 4 warnings, **zero of them in files this branch touches** | Verified two ways: (a) `git diff --stat origin/main..feat/p010-data-os-completion` on every flagged file returns no output — byte-identical to `main`; (b) ran `pnpm lint` against a worktree checked out at `origin/main` directly and got the identical 12/4. This is 100% pre-existing legacy noise (mostly `@typescript-eslint/no-explicit-any` in v0-generated adapters/tests), not introduced or worsened by this pass. |
| `pnpm run build` (`next build`, **CI-blocking in `.gitea/workflows/ci.yml`**; not run by `.github/workflows/ci.yml`, which needs runtime secrets Vercel owns) | **Succeeded** | Route manifest confirms the new surface is actually built: `/api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]}` and `/api/0pt/sources/{route,[sourceId],[sourceId]/{freshness,health,provenance,rights}}` all present as dynamic (`ƒ`) routes in the build output. |
| `python -m pytest -v` (`services/api`, fresh venv from `requirements-dev.txt`) | **28 passed, 2 skipped** | The 2 skips are `test_known_gaps.py`'s documented CDC/backfill-idempotency gaps (§4 above) — `pytest.skip(reason=...)`, not silent omission. **Not currently wired into either `.gitea/workflows/ci.yml` or `.github/workflows/ci.yml`** — both CI workflows' own comments say the Python engine "is gated separately if/when a Python workflow is added." This pass did not add that workflow (out of P010's stated scope); the 28/28 above is a manual verification, not an automated CI gate today. Flagging this explicitly as a residual gap, not glossing over it. |
**Net CI-blocking-gate status: all green** (typecheck, vitest, `next build`
in Gitea CI; typecheck + vitest in the GitHub CI mirror). Lint is advisory
everywhere and pre-existing failures are unrelated to this branch. Python
tests are green but not yet gated by any CI workflow — see the Gap list
below.
---
## 3. Files changed (47 files, +3119/-201, base `origin/main` → `9740e20`)
New route surface: `app/api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]}/route.ts`,
`app/api/0pt/sources/{route.ts,[sourceId]/route.ts,[sourceId]/{freshness,health,provenance,rights}/route.ts}`.
New Python: `services/api/workers/quality_bridge.py`, `services/api/pytest.ini`,
`services/api/requirements-dev.txt`, `services/api/tests/test_{connectors_provenance,known_gaps,pipeline_fault_injection,quality_bridge,resolve_determinism}.py`.
New TS repositories/migration: `src/repositories/*` (7 files), `sql/002_source_registry.sql`.
New TS quality bridge: `src/quality/quality-bridge.ts`, `src/services/quality.ts`, `src/services/source-registry.ts`.
Modified: `services/api/sources/connectors.py`, `services/api/workers/{pipeline,resolve}.py`,
`src/services/{source-registry-service,source-service,source-rate-policy-service}.ts`,
`src/validation/0pt-schemas.ts`, `src/http/siax-error-response.ts`, `.gitignore`
(added `.venv/`, `.pytest_cache/`), `sql/004_iceberg_write_boundary.sql` (minor).
Test files modified: `tests/p0/source-registry.test.ts` (repointed at shared
singleton), new `tests/p0/quality-bridge.test.ts`,
`tests/p0/source-registry-postgres.realinfra.test.ts`.
No changes to: `services/scrapers/` (the still-unmerged WAF-bypass deletion
PR — untouched, not this pass's scope), the `app/`-root Next.js UI pages
(`bestall/`, `katalog/`, `portal/`, etc. — untouched), any of the 5
untracked stray paths in the working tree.
---
## 4. Activating the pipeline bridge — Simon's decision, not made here
**`OPT_QUALITY_BRIDGE_ENABLED` ships unset (default `false`) and this pass
does not flip it, anywhere, including in any deploy config, `.env.example`,
or CI workflow.** This affects real customer orders once on — enabling it
in production is a separate decision, deliberately left to Simon.
To turn it on later, the following must all be true first (this pass built
the mechanism, not the readiness):
1. `OPT_QUALITY_BRIDGE_URL` (or `OPT_QUALITY_BRIDGE_EDGE_URL`, per
`quality_bridge.py`'s `_bridge_url()`) must point at a reachable,
authenticated `0pt.app` deployment — the Python engine and the Next.js
edge app are typically different deploy targets (VPS vs. Vercel/SIAX
Deploy Platform per the `siax-0pt` skill's two-plane model), so this is a
real cross-service network dependency, not a same-process flag flip.
2. Even fully enabled and reachable, the bridge remains **advisory-only by
design** — it stamps a `quality` field on delivered provenance and
surfaces a score; it does **not** and, per §2's `ExpectationEngine`
contract gap, currently **cannot** change which rows Python's
`delivered = [...]` line keeps or drops. Anyone enabling the flag
expecting stricter row-level gating will not get it from this code as
shipped — that would require the `ExpectationEngine` contract change
noted above, which is future work.
3. Recommended rollout, not yet executed: enable against one non-production
tenant/order first, confirm `bridge-run` round-trips correctly (the
manual live-verification in §1/§2 above did this once, ad hoc, during
development — it was not repeated against a staging environment as a
formal rollout step), then decide with Simon whether "advisory-only,
fails open" is the intended production posture or whether the per-row
gating gap needs to close first.
**This pass leaves the flag off. No default, config file, or workflow in
this PR turns it on.**
---
## 5. Residual gaps (honest, not exhaustive elsewhere in this doc)
- Python engine tests (28/28) are not wired into either CI workflow yet —
a `.gitea/workflows/`/`.github/workflows/` Python job is future work, not
part of this pass.
- `bridge_provenance` as a single unified construct still does not exist;
this pass narrows but does not close the TS/Python provenance-schema gap
(§3 above).
- Per-row quality gating from the TS engine into the Python pipeline is not
implementable against the current `ExpectationEngine` contract (§2, §4
above) — advisory-only is the ceiling of what this pass could honestly
build, not a corner cut.
- `resolve.py`'s greedy merge remains genuinely order-sensitive for
non-transitive-similarity triangles — tested and documented, not fixed
(fixing it is an algorithm change, out of scope here).
- CDC-interruption and true delivery-store backfill-idempotency tests remain
unimplementable because the underlying infrastructure (a CDC pipeline, a
delivery persistence store) does not exist anywhere in `services/api` —
documented via `pytest.skip` with reasons in `test_known_gaps.py`, not
silently dropped.
- 5 untracked/unaudited local paths (`src/fleet/`, `src/graph/`,
`src/metrics/`, `src/projections/`, `src/features/feature-registry.ts`)
remain exactly as found — not committed, not deleted, not audited by this
pass. They are not part of this PR's diff and a fresh clone never sees
them.
- `services/scrapers/` (WAF-bypass stack) remains on `main`, unmerged
deletion PR still open — unrelated to and untouched by this pass, flagged
again here only because `BEFORE_STATE.md` flagged it and it has not
changed.
---
## 6. Process notes
- No PR was open before this pass; one was opened as part of this pass (see
PR URL reported separately / in `PRODUCTION_EVIDENCE.json`).
- `BEFORE_STATE.md` and the 5 pre-existing untracked/unaudited paths were
left untouched and uncommitted throughout, exactly as found at the start.
- `.venv/` and `.pytest_cache/` (both created fresh under `services/api/`
while running the Python suite) were added to `.gitignore` so they are
never accidentally committed; no leftover Python processes.
- The `next dev` server and any background verification process used for
the live end-to-end bridge check (§1/§2) were both confirmed stopped
before this pass ended — no lingering listeners.
-15
View File
@@ -1,15 +0,0 @@
Copyright (c) 2026 SIAX Technology AB. All rights reserved.
0pt (SIAX Core Platform: Data OS) is proprietary software and a confidential
product of SIAX Technology AB. It is provided for internal use within SIAX
Technology AB and its authorized affiliates only.
No part of this software or its source code may be copied, modified, merged,
published, distributed, sublicensed, or sold outside of SIAX Technology AB
without the prior written permission of SIAX Technology AB.
Unless required by applicable law or agreed to in writing, this software is
provided "AS IS", without warranties or conditions of any kind, either express
or implied.
This is not an open-source license.
-37
View File
@@ -1,37 +0,0 @@
# BACKLOG P6 / DX-11. Thin wrappers around the real pnpm scripts -- this
# file is a convenience entry point, not a second source of truth for what
# each step does (see package.json for the actual commands).
.PHONY: dev doctor typecheck test lint build install db-up db-down
install:
corepack enable
corepack prepare pnpm@9 --activate
pnpm install --frozen-lockfile
dev: install
pnpm run dev
# Optional local Postgres (compose.dev.yaml, BACKLOG DX-02) -- `make dev`
# above works without this via the in-memory fallback (lib/db.ts). Run this
# first, then `DATABASE_URL=postgres://opt:opt-dev-only-not-a-secret@localhost:5432/opt make dev`
# for real persistence locally.
db-up:
docker compose -f compose.dev.yaml up -d
db-down:
docker compose -f compose.dev.yaml down
doctor:
pnpm run siax:doctor
typecheck:
pnpm run typecheck
test:
pnpm run test
lint:
pnpm run lint
build:
pnpm run build
-65
View File
@@ -1,65 +0,0 @@
# Onboarding — 0pt
BACKLOG P6 (DX-12). For a human or an agent picking this repo up cold.
## 1. Read, in order
1. `CLAUDE.md` — the contract for how this project is built. Non-negotiable
constraints (legal sourcing only, Postgres-backbone, provenance-per-field).
2. `AGENTS.md` — agent-specific ground rules.
3. `docs/architecture/ARCHITECTURE.md` — what the code actually does today
(as opposed to `docs/01-11`, which describe target state — see that
doc's own "Läsanvisning").
4. `README.md` "Known issues" — the live gaps, dated and evidenced.
5. `prompts/000-INDEX.md` — run `001-repo-health.md` before touching anything.
## 2. Get it running
```sh
corepack enable
corepack prepare pnpm@9 --activate
pnpm install --frozen-lockfile
pnpm run dev # http://localhost:3000, in-memory fallback, no DB needed
```
Or `make dev` (same thing). Want real Postgres locally instead of the
in-memory fallback? `make db-up` first (BACKLOG DX-02, `compose.dev.yaml` —
**unverified in CI, smoke-test before relying on it**), then re-run with
`DATABASE_URL` pointed at it (see the Makefile comment).
`pnpm run siax:doctor` (or `make doctor`) — runs the self-diagnostic subset
of the SIAX Golden Standard v2.0 checks locally (`scripts/siax-doctor.mjs`,
control AGENT-04). Run it after cloning and after any structural change.
## 3. Before you change anything
- `AUTH_ENFORCE` is fail-closed by default — don't set `AUTH_ENFORCE=false`
outside local dev, and never commit it set that way anywhere.
- `services/scrapers/*`, `services/infra/docker-compose.data-stack.yml`, and
`live/src/workers/adaptive_router.py`'s `curl_cffi` entries are frozen —
see `SECURITY.md`. Don't touch, remove, or comment on that code without an
explicit, current instruction from the account owner.
- Run `prompts/003-security-review.md`'s checklist before pushing anything
touching auth, payments, or the platform clients.
## 4. Before you push
```sh
pnpm run typecheck # blocking in CI
pnpm run test # blocking in CI
pnpm run build # blocking in CI
pnpm run lint # advisory, but fix what you can
```
CI (`.gitea/workflows/ci.yml`) re-runs all of these plus a blocking
Trivy/gitleaks scan (`security.yml`, BACKLOG P0-16/P0-19) — a red `trivy`
job on an otherwise-unrelated PR usually means a pre-existing dependency
finding, not something your change caused; check the run's date against
when that job started being blocking before assuming you broke it.
## 5. Who to ask
`CODEOWNERS` → `@sax3l` (Simon Axelsson). Two remotes exist for this repo
(`git.siax.io/sax3l/0pt` and `github.com/sax3l/0pt`) with independently
diverged history — which is authoritative is explicitly Simon's call, not
something to assume; see `CLAUDE.md`.
-20
View File
@@ -1,20 +0,0 @@
# 0pt — Masterplan-index (SIAX Non-Platform Masterplan)
> **AI-session i detta repo?** Börja i [siax-masterplan → EXECUTE.md](https://git.siax.io/sax3l/siax-masterplan/src/branch/main/EXECUTE.md)
**Repo:** `0pt` · **Domän:** Data OS (data engine, sourcing/enrichment) · **Kategori:** SIAX Non-Platform masterplan
**Statusvokabulär:** MISSING → SCAFFOLDED → IMPLEMENTED → WIRED → INTEGRATION_TESTED → LIVE_VERIFIED → PRODUCTION_READY. Legacy = `LEGACY`, bort först när ersättaren är live (ingen dubbel sanning).
## Låsta arkitekturprinciper (gäller detta repo)
1. **En canonical owner per domän och sanning** — detta repo äger sin
domän-sanning; andra ytor konsumerar via API/event/SDK, aldrig egen SQL.
2. Inga production-mock-fallbacks. Inga lokala fallback-sanningar.
3. `tenant_id`/`actor_id` server-resolved; ingen frontend-tenant-trust.
4. Allt API-first: kontrakt först (openapi.yaml), sedan surfaces.
> Detta är en stub skapad av conformance-sweep mot `siax-standard`
> (kriterium: masterplan-lock + PLAN-index). Fyll på med repo-specifik
> planstatus — redigera inte låst principerna. Kanonisk källa:
> [sax3l/siax-masterplan](https://git.siax.io/sax3l/siax-masterplan).
-175
View File
@@ -1,175 +0,0 @@
{
"report": "PRODUCTION_EVIDENCE.json",
"companion_doc": "IMPLEMENTATION_REPORT.md",
"generated_at": "2026-09-01T21:10:00+02:00",
"repo": {
"remote": "https://git.siax.io/sax3l/0pt.git",
"canonical_remote_per_claude_md": "git.siax.io/sax3l/0pt",
"note": "A second remote (github.com/sax3l/0pt) also claims to be this repo per CLAUDE.md; explicitly unresolved and left to Simon. This pass worked only against git.siax.io."
},
"branch": {
"name": "feat/p010-data-os-completion",
"renamed_from": "feat/p010-source-registry-persistence",
"base_branch": "main",
"base_commit": "f8bb0d5ab9f35c652d09f7639d20458cfe849f56",
"head_commit": "9740e20818a8ce0a90498bdb12c501bc74395ac",
"commits_on_branch": [
{
"sha": "07b13bd8331017462ead11b9b2ce8fa9188e520f",
"summary": "feat(data-os): Source Registry Postgres persistence + full HTTP route surface"
},
{
"sha": "9740e20818a8ce0a90498bdb12c501bc74395ac",
"summary": "feat(data-os): TS/Python quality bridge + provenance fields + fault-injection tests"
}
],
"pushed_to_origin_this_pass": true,
"diffstat_vs_base": {
"files_changed": 47,
"insertions": 3119,
"deletions": 201
}
},
"pull_request": {
"url": "https://git.siax.io/sax3l/0pt/pulls/28",
"number": 28,
"base": "main",
"head": "feat/p010-data-os-completion",
"state_at_report_time": "open",
"opened_this_pass": true
},
"verification_methodology": {
"description": "All numbers below were produced by a from-scratch verification pass: a fresh `git clone` of the pushed branch into an isolated directory, a fresh `pnpm install --frozen-lockfile`, and a fresh Python venv built from requirements-dev.txt. This is deliberately NOT a rerun inside the original working tree that developed the branch, to avoid reporting stale or environment-contaminated results.",
"clean_clone_path_used_for_verification": "isolated scratch directory, discarded after this pass",
"original_working_tree": "/home/simon/siax-platforms-work/0pt"
},
"checks": [
{
"name": "pnpm install --frozen-lockfile",
"result": "ok",
"note": "lockfileVersion 9, no resolution drift from the committed pnpm-lock.yaml"
},
{
"name": "typecheck",
"command": "pnpm run typecheck",
"underlying": "tsc -p tsconfig.check.json (src/**/*.ts + tests/**/*.ts)",
"ci_gate": "blocking",
"result": "pass",
"errors": 0,
"note": "Zero errors on the pristine clone. The original dev working tree separately contains 5 untracked/uncommitted stray paths (src/fleet/, src/graph/, src/metrics/, src/projections/, src/features/feature-registry.ts) that were already present before this pass (flagged in BEFORE_STATE.md as unaudited) and are NOT part of any commit on this branch or on main -- confirmed via `git diff --stat origin/main..feat/p010-data-os-completion` touching none of them, and via their total absence from the fresh clone. Running the same command inside that non-clean working tree surfaces 14 TS errors from those 5 files; that is local litter CI will never see, not a defect in this PR."
},
{
"name": "unit/integration tests (TypeScript)",
"command": "pnpm run test",
"underlying": "vitest run",
"ci_gate": "blocking",
"result": "pass",
"test_files_passed": 34,
"test_files_skipped": 3,
"tests_passed": 256,
"tests_skipped": 18,
"tests_new_this_branch": 7,
"new_test_file": "tests/p0/quality-bridge.test.ts",
"skip_reason": "pre-existing .realinfra/.integration suites self-skip honestly when no live ST0RE/OpenSearch/ClickHouse is reachable from the verification environment",
"sample_skip_stderr": "[realinfra] ST0RE (https://api.st0re.siax.io) not reachable this run (ST0RE responded HTTP 401) -- skipping honestly, not fabricating success."
},
{
"name": "lint",
"command": "pnpm run lint",
"underlying": "eslint src tests",
"ci_gate": "advisory (continue-on-error in both .gitea/workflows/ci.yml and .github/workflows/ci.yml)",
"result": "pre-existing failures only, zero introduced by this branch",
"errors": 12,
"warnings": 4,
"verification": [
"git diff --stat origin/main..feat/p010-data-os-completion on every flagged file returns no output -- byte-identical to main",
"ran `pnpm lint` against a worktree checked out directly at origin/main and got the identical 12 errors / 4 warnings"
]
},
{
"name": "build",
"command": "pnpm run build",
"underlying": "next build",
"ci_gate": "blocking in .gitea/workflows/ci.yml; not run by .github/workflows/ci.yml (needs runtime secrets Vercel owns)",
"result": "pass",
"note": "Route manifest confirms new surface is built: /api/0pt/quality/{bridge-run,issues,rules,rules/[ruleId],runs,runs/[runId]} and /api/0pt/sources/{route,[sourceId],[sourceId]/{freshness,health,provenance,rights}} all present as dynamic routes."
},
{
"name": "Python engine tests",
"command": "python -m pytest -v",
"cwd": "services/api",
"environment": "fresh venv, python3.12, requirements-dev.txt",
"ci_gate": "NOT WIRED INTO CI -- both .gitea/workflows/ci.yml and .github/workflows/ci.yml state the Python engine is gated separately if/when a Python workflow is added; this pass did not add that workflow",
"result": "pass",
"tests_passed": 28,
"tests_skipped": 2,
"skip_detail": [
{
"test": "test_cdc_interruption_not_implementable_against_real_code",
"reason": "no CDC pipeline exists anywhere in services/api"
},
{
"test": "test_true_backfill_idempotency_not_implementable_against_real_code",
"reason": "no delivery/order persistence layer exists anywhere in services/api"
}
],
"new_test_files": [
"services/api/tests/test_connectors_provenance.py",
"services/api/tests/test_known_gaps.py",
"services/api/tests/test_pipeline_fault_injection.py",
"services/api/tests/test_quality_bridge.py",
"services/api/tests/test_resolve_determinism.py"
]
}
],
"net_ci_status": {
"gitea_ci_blocking_gates": {
"typecheck": "green",
"test": "green",
"build": "green"
},
"github_ci_blocking_gates": {
"typecheck": "green",
"test": "green"
},
"advisory_only_not_blocking": ["lint (both workflows)", "typecheck:app (github only)"],
"unwired_gate_not_in_ci": ["python pytest (services/api) -- manually verified green, no automated workflow yet"]
},
"production_feature_flags": {
"OPT_QUALITY_BRIDGE_ENABLED": {
"default_shipped_value": "unset (treated as false)",
"changed_by_this_pr": false,
"activated_in_production_by_this_pass": false,
"behavior_when_off": "quality_bridge.py makes zero network calls, returns status=disabled immediately; pipeline.py's delivery decision is completely unaffected",
"behavior_when_on_but_bridge_unreachable": "fails open -- status=unavailable, delivery decision unaffected, error never raised into run_job",
"behavior_when_on_and_reachable": "advisory only -- stamps a quality field on delivered provenance and records a score; does NOT and, per the current ExpectationEngine contract, currently CANNOT change which rows pipeline.py's confidence-threshold filter keeps or drops",
"decision_owner": "Simon",
"readiness_checklist_before_enabling": [
"OPT_QUALITY_BRIDGE_URL / OPT_QUALITY_BRIDGE_EDGE_URL must point at a reachable, authenticated 0pt.app deployment (cross-service network dependency -- Python engine and Next.js edge are typically different deploy targets)",
"accept that this remains advisory-only (no per-row gating) unless the ExpectationEngine contract is separately extended to return per-row results",
"recommended: enable against one non-production tenant/order first and confirm bridge-run round-trips before any production enablement"
],
"manual_live_verification_performed_this_pass": {
"what": "real `next dev` server (AUTH_ENFORCE=false, local only) + real Python pipeline hitting real brreg.no + real bridge-run round-trip + real QualityService score returned",
"when": "during development of commit 9740e20, 2026-09-01",
"repeatable_automated_equivalent": "services/api/tests/test_quality_bridge.py (respx-mocked bridge responses, run as part of the 28/28 pytest total above)",
"note": "the live run was a one-off manual verification during development, not a formal staging rollout step, and not itself part of the from-scratch CI-mirroring verification in this report"
}
}
},
"residual_gaps": [
"Python engine tests (28/28) not wired into any CI workflow yet",
"bridge_provenance as a single unified TS/Python construct still does not exist -- this pass narrows, does not close, the provenance-schema split",
"per-row quality gating from TS into Python is not implementable against the current ExpectationEngine contract -- advisory-only is the ceiling of this pass, not a corner cut",
"resolve.py's greedy merge remains genuinely order-sensitive for non-transitive-similarity triangles -- tested and documented, not fixed (algorithm change, out of scope)",
"CDC-interruption and true delivery-store backfill-idempotency tests remain unimplementable -- no CDC pipeline or delivery persistence layer exists anywhere in services/api",
"5 untracked/unaudited local paths (src/fleet/, src/graph/, src/metrics/, src/projections/, src/features/feature-registry.ts) left exactly as found -- not part of this diff, absent from a fresh clone",
"services/scrapers/ (WAF-bypass stack) remains on main with its deletion PR still open -- unrelated to and untouched by this pass"
],
"process_notes": {
"pr_open_before_this_pass": false,
"before_state_md_left_untouched": true,
"gitignore_additions": [".venv/", ".pytest_cache/"],
"leftover_processes_confirmed_stopped": true
}
}
-117
View File
@@ -1,117 +0,0 @@
# 0pt — your data
Data services on demand for the Nordics + Europe. Compose a data job, pay 50 % to start,
watch production live, validate with full provenance, deliver anywhere. Owned by **SIAX
Technology AB**. Honest accuracy is the moat: every field carries source, freshness and
confidence; nothing is guessed.
**Live:** https://0pt.app · **Contract:** [`CLAUDE.md`](CLAUDE.md) + [`docs/`](docs/)
> **Architecture (verified against code, not aspirational):**
> [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md).
> It documents the real API surface, the real persistence story, and the known
> security gaps — including several places where this README is optimistic. Read
> it before trusting any capability claim below. Repo identity + estate
> classification: [`siax.repo.json`](siax.repo.json) +
> [`siax.config.json`](siax.config.json). Compliance state:
> [`docs/compliance/`](docs/compliance/) (INVENTORY, RELATIONS, OSS-INVENTORY,
> GOLDEN-STANDARD-REPORT, BACKLOG, SLUTRAPPORT).
## Two planes (see [docs/02-architecture.md](docs/02-architecture.md))
- **Edge (Vercel, this app)** — marketing, catalog, the order lifecycle, customer portal,
admin portal, and the API route handlers. Defers to the data engine when `ENGINE_URL` is set.
- **Engine (`services/api`, runs on a VPS)** — FastAPI orchestrator + worker pipeline doing
the **compliant** sourcing (official API → bulk → authorized web → licensed → else level 0).
Never on Vercel (long jobs). Bypass/stealth/proxy-rotation/fake-account paths are excluded
by design (CLAUDE.md §1).
## Routes
| Area | Routes |
|---|---|
| Marketing | `/` (hero, filosofi, katalog, motorn, visualiseringar, täckning, editorial, bevis, priser, footer) |
| Catalog | `/katalog`, `/katalog/[id]` |
| Sourcing map | `/kallor` |
| Order lifecycle | `/bestall` → `/bestall/forskott` → `/order/[id]` → `/order/[id]/validera` |
| Customer portal | `/portal`, `/portal/subscriptions`, `/portal/files`, `/portal/integrationer`, `/portal/settings` |
| Admin portal | `/admin`, `/admin/sources`, `/admin/jobs`, `/admin/customers` |
| API | `/api/{health,estimates,catalog,sources,orders,orders/[id],subscriptions,admin/metrics,uploads,auth/*,payments/*,webhooks/stripe,connections/db-grant}` |
## Design system
Mono/editorial, light, radius 0, Inter + JetBrains Mono, single red `#e40014`, animated grain,
scroll-reveal motion. **No photography** — imagery is rendered data (`DataArtifact`). Every fact
shows a provenance square (`ProvenanceBadge`). Full spec: [docs/05-design-system.md](docs/05-design-system.md).
## Run locally
**SIAX toolchain-kontrakt (mise):**
```bash
mise install # pinnar node 22.13.0 + pnpm 9.15.9 (se mise.toml [tools])
mise run setup # pnpm install --frozen-lockfile
mise run check # typecheck + lint (snabbt, deterministiskt)
mise run verify # vitest + next build (full svit)
mise run build # deploybar artefakt
```
mise är task-entrypoint (`mise.toml`); gate-logiken bor kvar i
`.gitea/workflows/` och package.json — pinning: `.siax/template-pin.json`.
```bash
pnpm dev # Next.js on http://localhost:3000
# data engine (separate, optional)
cd services/api && pip install -r requirements.txt && uvicorn orchestrator.main:app --reload
```
## Deploy
- **Config target (as of 2026-08-09):** `siax.config.json` declares `deploy.target: "coolify"`,
matching every other SIAX Cloud OS repo (n0d, st0re, aud0, ...) — a Dockerfile "for Coolify"
(already committed, see [`Dockerfile`](Dockerfile)) plus [`.gitea/workflows/cd.yml`](.gitea/workflows/cd.yml)
(renamed from `deploy.yml` 2026-08-10) that POSTs a restart to a Coolify app UUID on
`COOLIFY_TOKEN` push.
- ~~**Actually live right now:** the only running instance of `0pt.app` is still on **Vercel**
(project `0pt`, scope *Simon Axelsson's projects*) — no Coolify app has been provisioned for
this repo yet, so `deploy.yml`'s app UUID is a placeholder that fails the job on purpose until
someone fills it in. Flipping the config declares intent; it does not move traffic.~~ See
[`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) §5/§8 for the full
"Vercel or fleet" state and what's still open (Coolify app provisioning + DNS cutover — a
separate decision from this config change, since the live Vercel deployment is not being
touched).
- **CORRECTED 2026-09-06 — the bullet above is no longer true. The Coolify deploy hook is LIVE.**
The repo secret `COOLIFY_TOKEN` exists (created 2026-08-31T11:34:34Z) and the repo Actions
variable `COOLIFY_APP_ID` holds a real 24-character UUID, **not** the
`TODO-no-coolify-app-provisioned-yet` literal. Both guard conditions in
[`.gitea/workflows/cd.yml`](.gitea/workflows/cd.yml) therefore pass. Measured on the push to
`main` at `1ff34410` (2026-09-06): job `Deploy to Coolify / deploy` (run 11695, job 26295)
finished **success** with `coolify deploy HTTP 200` in its log. **Every merge into `main` now
fires a real redeploy against `cloud.siax.io` — know what you are shipping before you merge.**
**Probed 2026-09-06 08:00 UTC:** `0pt.app` still answers from **Vercel** (`server: Vercel`, `x-vercel-id: arn1::…`) and returns **HTTP 402 `DEPLOYMENT_DISABLED` — "Payment required"** on both `/` and `/api/health`. So the public domain is *down*, DNS has **not** been cut over to Coolify, and the Coolify app — whatever state it is in — serves no `0pt.app` traffic. Two separate facts, both true at once: the deploy hook fires for real, and the product domain is dark behind an unpaid Vercel deployment.
Still not established: whether the Coolify app itself builds and starts — the deploy endpoint
returning 200 only means the request was accepted, not that a container came up healthy.
- **Engine + DB + embeds** → one VPS via [`infra/docker-compose.yml`](infra/docker-compose.yml) (Postgres+pgvector, Redis, api, worker, NocoDB, Metabase, Caddy).
- Env inventory: [`.env.example`](.env.example).
- **Auth on `/api/0pt/*` is fail-closed**: with `AUTH_ENFORCE` unset the middleware
REQUIRES a verified Zitadel Bearer token (401 without one). Only the literal
`AUTH_ENFORCE=false` disables it, and that is refused when `NODE_ENV=production`
(the process throws at startup rather than serving anonymously).
**Caveat (live-verified 2026-08-10):** the deployed instance still answers
`GET /api/0pt` with 200 anonymously — the middleware appears not to run in
production (gap S1, root cause unknown). Do not rely on this being enforced
until an anonymous request gets a real 401.
- **Fonts:** self-hosted via `@fontsource-variable/inter` +
`@fontsource-variable/jetbrains-mono` (no Google Fonts / `next/font/google`).
- **API contract (SSOT):** [`contracts/openapi.yaml`](contracts/openapi.yaml).
## Status
Frontend, full order lifecycle, customer + admin portals, edge API, payment/auth/embed
infrastructure, the compliant data engine, schema and infra are all in place and building green.
Integrations are env-guarded and activate when their keys/DB are provided:
1. **Database** — Vercel → Storage → Postgres (sets `POSTGRES_URL`) → durable persistence (schema in [`sql/001_init.sql`](sql/001_init.sql)).
2. **Stripe + Resend** keys → payments + email.
3. **VPS** for the engine → set `ENGINE_URL`.
4. **`AUTH_SECRET` + `DEV_LOGIN_KEY`** → login (full OAuth via Auth.js lands with the DB).
© 2026 SIAX Technology AB. Not affiliated with Protosell.
-98
View File
@@ -1,98 +0,0 @@
# Security Policy
0pt (SIAX Core Platform: Data OS) is proprietary software operated by SIAX
Technology AB. This document explains how to report a suspected security
vulnerability.
## Reporting a vulnerability
Do not open a public GitHub issue for a suspected security problem.
Email: security@siax.io (fallback: simon@siax.io)
Include, if possible:
- A description of the issue and the impact you believe it has.
- Steps to reproduce, or a proof-of-concept request/response.
- The route, endpoint, or component affected.
- Whether the issue is already publicly known or exploited.
## Response expectations
- Acknowledgement: within 2 business days.
- Initial triage and severity assessment: within 5 business days.
- Remediation timeline: communicated after triage, based on severity.
Confirmed high/critical issues are prioritized ahead of other work.
We will keep you informed of progress until the report is resolved and, where
appropriate, credit you in internal release notes (no public bug bounty
program exists at this time).
## Scope
In scope:
- The 0pt application and its API routes at 0pt.app.
- The 0pt engine/orchestrator services in this repository.
- Infrastructure-as-code and CI/CD configuration in this repository.
Out of scope:
- Third-party services and dependencies not maintained in this repository
(report those to the respective vendor).
- Denial-of-service testing, spam, or social engineering against SIAX staff
or customers.
- Automated scanning that generates excessive load against production
systems without prior coordination.
Note: `github.com/sax3l/0pt` (this checkout's `origin`) and
`git.siax.io/sax3l/0pt` (Gitea) are two diverged remotes that share part of
this codebase's history — see README "Known issues" for what's known and
what isn't about the relationship between them. Which one is deployed to
0pt.app is currently unconfirmed; if your finding is remote-specific, say
which remote/branch you tested against.
## Known, already-tracked gaps
The following are **not** news to us — they are open, named gaps as of this
pass, not secrets, and reporting them again mainly helps confirm priority
rather than surfacing something new:
- **Zitadel bearer verification exists in code (fail-closed), but the deployed
instance still answers `/api/0pt/*` anonymously (gap S1).** `src/auth/zitadel.ts`
+ `middleware.ts` implement real JWKS-based verification with a fail-closed
default (`assertAuthConfigSafe()` refuses `AUTH_ENFORCE=false` in production),
yet a live probe (2026-08-10) got HTTP 200 without any token. Root cause is
unconfirmed — do not treat the public instance as authenticated until an
anonymous request receives 401. Additionally, no handler reads the verified
tenant headers (`x-0pt-verified-tenant`): the P0 routes under `/api/0pt/*`
(`lakehouse/mappings`, `sources/rate-policies`, `sources/[sourceId]/query`,
`documents/capture`) accept `tenant_id`/`project_id` **from the client-supplied
request body or query string** rather than from a server-bound session — a
violation of this platform's own tenant-isolation rule (tenant_id must never be
trusted from client input). This is an open, prioritized gap (BACKLOG P0-1/P0-2),
not an intentional design.
- **The non-`/api/0pt` API surface has no authentication at all.** Orders,
admin metrics, payments (including the escrow `release` that flips an order to
`delivered`), subscriptions, uploads and DB-grant endpoints are unprotected in
code (live-verified 200 anonymously on `/api/orders` and `/api/admin/metrics`).
- **The WAF-bypass scraper stack is kept deliberately.** `services/scrapers/*`
(`waf_bypass.py`: FlareSolverr, Tor, cloudscraper, curl_cffi, proxy rotation,
fake identities) and `services/infra/docker-compose.data-stack.yml`
(flaresolverr/tor containers) contradict this repo's own compliance docs.
A cleanup PR that surgically removed this stack (PR #7, verified clean, 128/128
tests) was closed unmerged by the account owner. **The account owner, Simon
Axelsson, was asked directly via an interactive decision prompt on 2026-08-11
whether to keep or remove this code, and explicitly chose to keep it.** No
business or legal justification beyond "keep it" was recorded — none is
invented here. This is a narrow, scoped exception for the existing 0PT/0pt-motor
sourcing engine; it does not change `CLAUDE.md` §1's general anti-evasion policy,
which remains in force as written unless/until the account owner says otherwise.
Out of scope for deletion — see `docs/compliance/GOLDEN-STANDARD-REPORT.md`
control C6.
- Beyond those items, other authentication gaps on internal API routes may still
exist and are tracked internally pending an intentional, reviewed fix; reporting
any you find is still welcome and helps confirm priority.
## Disclosure
Please give us a reasonable opportunity to investigate and remediate an
issue before any public disclosure. We aim to coordinate disclosure timing
with reporters wherever possible.
-277
View File
@@ -1,277 +0,0 @@
# 0pt — capability status (Master-DoD §56 status honesty)
> Classification scale (Master-DoD §2, exactly one per capability):
> `MISSING → DOCUMENTED_ONLY → SKELETON → MOCK_ONLY → IMPLEMENTED → INTEGRATION_TESTED → LIVE_VERIFIED → PRODUCTION_READY`.
> Only `PRODUCTION_READY` may be called DONE. Docs ≠ implementation.
> Last full audit + re-classification: **2026-08-31** (SIAX Platforms P0 wave 2).
## How to read this
- **INTEGRATION_TESTED** here means: verified against a REAL dependency
(live OpenSearch/ClickHouse container, or a recorded wire-contract double
pinned to the sibling repo's real handler code on its `main`) in CI.
- **LIVE_VERIFIED** requires a verification against the actual SIAX fleet
deployment. No new capability in this wave claims it.
- The unit/contract/integration suites are in `tests/` and run in
`.gitea/workflows/ci.yml` (jobs `quality` + `integration`).
## Cross-product clients (Master-DoD §10 "Cross-product clients")
| Capability | Status | Evidence | Honest gaps |
|---|---|---|---|
| CL0UD client (policy evaluate, `POST /api/policy/evaluate`) | **INTEGRATION_TESTED** (contract) | `src/clients/cl0ud-client.ts`; `tests/contracts/cl0ud-client.contract.test.ts` pins the real camelCase `{data}` envelope; fail-closed (missing decision ⇒ deny) | Not yet exercised against a live CL0UD deployment (no reachable CL0UD URL this session ⇒ not LIVE_VERIFIED) |
| CL0UD capability discovery (`GET /api/capabilities`) | **INTEGRATION_TESTED** (contract) | same suite | 0pt's own capabilities are not yet registered in CL0UD's registry (§3 gap) |
| ST0RE client (artifact register, camelCase `/v1/st0re/artifacts/register`) | **INTEGRATION_TESTED** (contract) | `src/clients/st0re-client.ts`; `tests/contracts/st0re-client.contract.test.ts` | Raw-bytes upload still goes via LocalBlobStore interim (pre-existing, unchanged); presign upload path not consumed yet |
| N0D client (create/get job, get result) | **INTEGRATION_TESTED** (contract) | `src/clients/n0d-client.ts`; `tests/contracts/n0d-client.contract.test.ts` pins `required_role`/`resource_requirements`/body `idempotency_key` | CloudEvents completion callback receiving endpoint not built in 0pt (poll path works) |
| AUD0 client (events + OpenLineage ingest) | **INTEGRATION_TESTED** (contract) | `src/clients/aud0-client.ts`; `tests/contracts/aud0-client.contract.test.ts`; legacy lineage shape converts to real OpenLineage | No durable local outbox for AUD0 outages (§70 "durable local/outbox path" — emission failure is surfaced, not queued) |
| **No production mock fallback (§3/§10)** | **INTEGRATION_TESTED** | `src/platform/dependency-mode.ts`; `tests/contracts/dependency-mode.contract.test.ts` drives NODE_ENV=production for all four factories ⇒ hard `DEPENDENCY_UNAVAILABLE` | — |
**Correction of prior state (2026-08-31):** the pre-existing clients' HTTP
paths were verified against the sibling repos' actual `main` branches and
THREE of four were wrong: cl0ud (`/v1/cl0ud/policies/evaluate` — never
existed), st0re (snake_case body — would 400), aud0 lineage
(`/v1/aud0/lineage/events` — never existed). All fixed and pinned by
contract tests. The earlier MOCK_ONLY classification in the README was
accurate.
## Data-OS capabilities (Master-DoD §10)
| Capability | Status | Evidence | Honest gaps |
|---|---|---|---|
| Source Registry — register/query/describe/health, rights/license/purpose, freshness, provenance | **IMPLEMENTED** | `src/services/source-registry-service.ts`, `src/types/source-registry.ts`; `tests/p0/source-registry.test.ts` (fail-closed purpose gate, cross-tenant NOT_FOUND) | In-memory persistence (per-process); no Postgres store yet — production boot must therefore configure one before this counts further. No API route surface yet |
| Source rate-policy | **IMPLEMENTED** (pre-existing) | `src/services/source-rate-policy-service.ts` | unchanged this wave |
| Search — OpenSearch adapter, index defs, bulk, tenant + purpose/rights filter, rebuild, generation switch | **INTEGRATION_TESTED** | `src/search/*`; unit `tests/p0/search-service.test.ts`; **real OpenSearch 2.11** `tests/integration/search-opensearch.integration.test.ts` incl. projection-disaster rebuild (§68-style) — runs in CI service container | Hybrid (vector) query not built; no metrics emission; index definitions in-memory |
| Quality — versioned rules, runs, results, issues, score, trend, GE interchange | **IMPLEMENTED** (local engine) / **SKELETON** (GE delegation) | `src/quality/*`; `tests/p0/quality-service.test.ts`, `tests/p0/expectation-engine` cases | Local engine covers 7 GE core expectation types; everything else delegates via N0D job `0pt.quality.ge_run` whose EXECUTOR (0pt-motor GE runner) does not exist yet — delegated runs stay "delegated" until it does. Never fabricates results |
| Dataset — create (pre-existing) + version/query/export with `st0re:artifact:` refs | **IMPLEMENTED** | `src/services/dataset-version-service.ts`; `tests/p0/dataset-version-service.test.ts` (idempotency §53, canonical refs §5) | Export assumes bytes already written by a job; no route surface; in-memory persistence |
| Lineage — OpenLineage emission to AUD0's real ingest | **INTEGRATION_TESTED** (contract) | `src/lineage/*`; aud0 contract suite; emitted from dataset version/export, quality runs, search rebuilds | Emission is best-effort (never-throw); no outbox. AUD0-side ingestion verified against contract double, not live AUD0 |
| Metadata — OpenMetadata projection (never canonical) | **IMPLEMENTED** | `src/metadata/openmetadata-adapter.ts`; `tests/contracts/metadata-features.contract.test.ts`; `projectAll` rebuild path | No live OpenMetadata exercised (no local container this pass); lineage/glossary/ownership projection not built — only dataset entities |
| Analytics — ClickHouse, tenant-isolated, query limits, retention | **INTEGRATION_TESTED** | `src/analytics/clickhouse-analytics.ts`; **real ClickHouse 24.8** `tests/integration/analytics-clickhouse.integration.test.ts` (tenant isolation, closed DSL, TTL schema) — runs in CI service container | Closed metric-DSL only (no ad-hoc queries by design); no quota integration with CL0UD |
| Features — registry + Feast adapter | **IMPLEMENTED** (online path, registry) / **SKELETON** (materialize/get-historical) | `src/features/feast-adapter.ts`; contract suite pins Feast's `get-online-features` wire shape | No live Feast server exercised; offline/materialization are N0D delegation contracts whose executor (0pt-motor) does not exist yet; point-in-time correctness therefore NOT yet proven |
| Entity Resolution — candidate API, blocking, explanation, hard-rule hook, review handoff | **IMPLEMENTED** | `src/er/entity-resolution-service.ts`; `tests/p0/entity-resolution.test.ts` (hard rules override both directions, review band, blocking refusals) | Generic reference scorer (Jaccard+Levenshtein), NOT Splink probabilistic linkage — that is the `0pt.er.batch_resolution` N0D contract, executor pending in 0pt-motor. No persistence of review queues |
| Graph (§10 Graph) | **MISSING** | — | Not started this wave (explicitly out of the delivered P0 subset; next in line per §10) |
## Canonical platform plumbing
| Capability | Status | Evidence |
|---|---|---|
| SiaxRequestContext + propagation headers (mirror of cl0ud `@siax/contract`) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/siax-context.ts`; `tests/contracts/siax-context.contract.test.ts` |
| Canonical 12-code error model + typed `SiaxError` (§6) | **INTEGRATION_TESTED** (contract-pinned) | `src/platform/errors.ts`; same suite |
| Canonical resource refs (§5) | **IMPLEMENTED** | `src/platform/resource-ref.ts`; `tests/p0/dataset-version-service.test.ts` |
## Pre-existing capabilities (unchanged this wave — prior classifications stand)
| Capability | Status | Note |
|---|---|---|
| Iceberg write boundary via ST0RE lakehouse | LIVE_VERIFIED (2026-07-13) | `docs/ICEBERG_WRITE_BOUNDARY.md`; untouched |
| Document capture/extraction | IMPLEMENTED | LocalBlobStore interim persists bytes; ST0RE raw upload still pending |
| Edge order/payment app (Next.js) | see `docs/architecture/ARCHITECTURE.md` | outside this wave's scope |
| FastAPI engine (`services/api`) | IMPLEMENTED | outside this wave's scope |
| `services/scrapers/*` (WAF-bypass stack) | **quarantined, pending deletion PR** | `feature/0pt-remove-noncompliant-scrapers` — NOT touched by this wave; nothing new wires it |
## Global gaps blocking PRODUCTION_READY (§3, tracked, not hidden)
1. Persistent stores REMAINING: quality runs/rules and feature views are
still per-process in-memory (`resolveStoreMode` guarantees production
boot FAILS rather than serving Maps). CLOSED since the last revision:
dataset registry (`opt_datasets`) + immutable versions
(`opt_dataset_versions`, monotonic under transaction lock, idempotency
with payload-drift detection, cross-tenant ⇒ NOT_FOUND) and versioned
search index definitions (`opt_search_index_definitions`, sequential
schema versions, replay-idempotent, same-version-different-schema ⇒
CONFLICT; OpenSearch remains a rebuildable projection, never canonical
truth) are Postgres-canonical with real-Postgres tests.
2. Capability registration in CL0UD + API0 discovery for the new
capabilities (§3 "Capability är registrerad i CL0UD Capability
Registry") — PARTIALLY CLOSED: the collaboration/meeting domain was
canonicalized in CL0UD's registry (cl0ud #73/#74); 0pt's own
capability set still needs the same registration.
3. CLOSED: AUD0 outbox for evidence durability under AUD0 outage (§70) —
durable Postgres outbox + dispatcher (at-least-once, bounded batch,
backoff, dead-letter, SKIP LOCKED leasing, tenant binding, idempotency)
+ real-Postgres tests; dispatcher uses explicit `durable:false` so it
never re-enqueues its own delivery evidence.
4. CLOSED for datasets/versions/search/ER: `app/api/0pt/datasets*`,
`app/api/0pt/search/definitions` + tenant-safed query route, and
`app/api/0pt/er/{candidates,batch}` (+ internal ER route) are live;
clients cannot raise `rights_tag` via request body (fail-closed to
`internal`). Quality routes exist (`bridge-run/issues/rules/runs`).
5. 0pt-motor executors for the delegated jobs
(`0pt.quality.ge_run`, `0pt.features.*`, `0pt.er.batch_resolution`).
6. Observability: no metrics/traces emitted by the new services (§36).
---
## P060-P068 ingestion plane (added 2026-09-04)
Classification scale unchanged. Every row below states what was actually
verified, and by which command.
| Requirement | BEFORE this pass | Status now | Evidence |
|---|---|---|---|
| **P060** Source Registry: durable canonical registry; restart preserves sources; an unclassified source cannot be activated | **PARTIAL / BROKEN.** The registry existed with Postgres persistence (`sql/002_source_registry.sql`, `src/repositories/postgres-source-registry-repository.ts`) and carried source_id/tenant/project/type/status/license/purpose/rights. It had NO authorization, NO schedule, NO checkpoint, NO schema, and no `owner` distinct from `created_by`. The activation DoD was inverted: `postgres-source-repository.ts:70` wrote `${input.status ?? "active"}`, so a source with no rights, no provenance and no authorization was ACTIVE the instant it was registered, and no activation operation existed at all | **INTEGRATION_TESTED** | `sql/007_ingestion_plane.sql` (authorization/schedule/schemas/checkpoints + `ALTER … SET DEFAULT 'pending'` + `owner_id`); `SourceRegistryService.classifySource`/`activateSource`; `tests/p0/source-activation-gate.test.ts` (11); `tests/api/0pt-p060-routes.test.ts` (9); `tests/p0/ingestion-plane-postgres.realinfra.test.ts` against a live Postgres 16 |
| **P061** Connector contract: discover/validate/connect/checkpoint/read/resume/backfill/health/disconnect; two heterogeneous connectors pass one conformance suite | **MISSING.** `services/api/sources/connectors.py` had per-source `fetch_*()` functions only — no lifecycle, no cursor, no resume, no health, no disconnect, no conformance suite | **INTEGRATION_TESTED** | `src/connectors/connector.ts` (all nine operations); `src/connectors/conformance.ts` (15 checks); `HttpJsonConnector` (real node:http server, opaque cursor) and `JsonlFileConnector` (real file, line-offset cursor, CDC deletes) both pass the identical suite — `tests/p0/connector-conformance.test.ts`, which also proves the suite FAILS a fail-open connector |
| **P062** CDC/batch/stream normalized to one envelope; restart resumes from a durable checkpoint without silent gaps | **MISSING**, and documented as such: `services/api/tests/test_known_gaps.py:10-19` skipped the CDC case because no CDC pipeline existed anywhere, and `workers/pipeline.py` had no persistence at all | **INTEGRATION_TESTED** | `src/types/ingestion.ts` (one envelope for all three modes); `src/ingestion/ingestion-runner.ts`; `tests/p0/ingestion-runner.test.ts` kills a run inside the sink and proves the resumed run covers all 10 records exactly once; same proof against real Postgres across fresh processes |
| **P063** Schema versioning + compatibility policy; a breaking schema is detected before consumer corruption | **MISSING.** `schema_version` existed as a free-form string on datasets/Iceberg writes; there was no comparison, no policy, no versioned source schema | **INTEGRATION_TESTED** | `src/schema/compatibility.ts`, `src/schema/schema-registry.ts`; `tests/p0/schema-evolution.test.ts` (12); the runner's schema gate runs before the first read — `tests/p0/ingestion-runner.test.ts` asserts `sinkCalls === 0` on a break |
| **P064** Quality rules behind the SIAX abstraction; failures block or quarantine per declared policy | **PARTIAL.** `src/quality/*` had rules, runs, results, issues, scores and a GE-compatible engine, but a failing rule only opened an ISSUE — the data flowed on regardless. `grep -ri quarantin src/` found one unrelated hit | **INTEGRATION_TESTED** | `src/quality/quality-policy.ts` composes the EXISTING `ExpectationEngine` (not a second engine) and adds the consequence; block / quarantine / warn each proved in `tests/p0/ingestion-runner.test.ts`; quarantine durability proved against real Postgres |
| **P065** Normalization/ER: only the canonical highway; no second simplified production engine | **CLOSED.** `services/api/workers/resolve.py` is now a thin adapter: Python no longer scores identity — it POSTs opaque records to `/api/0pt/internal/er/candidates` and applies ONLY explicit hard-rule `match` decisions (score/fuzzy can never merge records, even from a compromised bridge); canonical engine unreachable ⇒ records return UNMERGED, never fallback-scored. Company semantics `stable-identifiers.v3`: LEI global; org_nr hard identity only within the same registration_country; punctuation normalized; negative stable identity dominates every positive; fuzzy alone ⇒ human review, never auto-merge. Cluster-wide hard-negative constraints: A≈B ∧ B≈C never forces A≈C when canonical identity says A≠C | **INTEGRATION_TESTED** | `tests/p0/entity-resolution.test.ts` (v3 semantics incl. hard-negative transitivity), `tests/p0/entity-resolution-http.test.ts` (internal route fails closed: empty/wrong token ⇒ 401), `services/api/tests/test_resolve_determinism.py` (adapter contract: canonical edges only), shared request validation (`src/er/request-validation.ts`, records 2..5000), blocking pytest gate (`.gitea/workflows/python-engine.yml`) |
| **P066** Iceberg | out of scope — ST0RE is owned by another workflow | **NOT TOUCHED** | Contract read only (`src/services/iceberg-write-service.ts`, `src/clients/st0re-lakehouse-client.ts`) |
| **P067** Lineage: OpenLineage/AUD0 events; source → output lineage is queryable | **PARTIAL.** `src/lineage/lineage-emitter.ts` emitted OpenLineage to AUD0, but nothing in this repo could ANSWER a lineage question — no store, no query, no route | **INTEGRATION_TESTED** | `src/lineage/lineage-edge.ts`, `src/lineage/lineage-store.ts` (local queryable projection; AUD0 stays the system of record), `GET /api/0pt/lineage`; `tests/p0/ingestion-runner.test.ts` queries both directions and proves tenant scoping |
| **P068** Durable bounded backfill/replay; repeated backfill is idempotent | **MISSING**, documented as such: `services/api/tests/test_known_gaps.py:44-52` skipped it because "no delivery-store persistence layer exists to dedupe a backfill against" | **INTEGRATION_TESTED** | `opt_ingestion_ledger` + `IngestionRunner.backfill`; `tests/p0/ingestion-runner.test.ts` (repeat emits 0, live checkpoint untouched, a real content change still re-emits); same against real Postgres |
### What was actually run (not "should pass")
- `pnpm run typecheck` — clean.
- `pnpm run test` — **365 passed, 25 skipped** (was 311/18 before this pass).
- `pnpm run build` — clean; the four new routes appear in the route table.
- `pnpm run lint` — 12 errors / 5 warnings, **all pre-existing** (identical
count on the base commit; advisory in CI). This pass adds none.
- Real Postgres 16: `tests/p0/ingestion-plane-postgres.realinfra.test.ts`
with `INGESTION_TEST_DATABASE_URL` — 7 passed.
- **Real container restart**, run manually against that same database:
`docker restart` the Postgres container, then re-read through the real
repositories. Before and after were identical — 2 sources (one `active`
with its owner, one `pending`), 1 checkpoint (cursor 6, 6 committed),
6 ledger rows, 1 lineage edge, 4 quarantined records, 1 schema version.
That is P060's "a restart preserves all sources", measured rather than
asserted.
### A real bug this pass wrote and then caught
The first draft of `IngestionRepository` combined the idempotency check and
its recording into one atomic call, made BEFORE the sink. The crash/resume
test then delivered **7 of 10 records**: the page in flight when the sink
died had already been marked delivered, so the resumed run skipped it as a
duplicate — permanent, silent data loss. The fix is the `peekDeliveries` /
`commitDeliveries` split, with both the ledger commit and the checkpoint
commit after the sink resolves. Recorded here because the failure mode is
the interesting artifact, not the fix.
### Honest gaps in the new plane (not hidden)
1. **Delivery is AT-LEAST-ONCE, not exactly-once.** Two concurrent runs on
the same (source, partition) can both peek "new" and both emit.
`envelope_id` is deterministic so a dedup-capable sink survives it. There
is no per-(source, partition) run lock. Do not describe this plane as
exactly-once.
2. **No CDC *source* exists.** The envelope, the operations and the runner
handle change events, and `JsonlFileConnector` emits real deletes from an
`_op` field — but this repo still has no Debezium/WAL/replication-slot
consumer. P062's contract is met; a CDC connector is still MISSING.
3. **0pt does not schedule itself.** `opt_source_schedule` is a declaration
ACT0 would read; there is no cron daemon here.
4. **The sink is caller-supplied.** The runner stops at the sink; the
lakehouse write boundary is ST0RE's (P066, another workflow's repo).
5. **Nothing here is deployed.** See the deployment note below.
6. **`QualityService`'s own rule/run/issue registry is still in-process
Maps** (`src/quality/quality-service.ts:47-50`). The new *enforcement*
path is durable; the older rule registry is not. Unchanged by this pass.
7. **Python tests still run nowhere.** `grep -rn "pytest" .gitea/workflows
.github/workflows` finds nothing — `services/api/tests/*` (9 files) has
never been executed by CI. Flagged, not fixed (it needs a workflow change
and a Python toolchain decision).
### Deployment reality, measured 2026-09-04
`https://0pt.app/api/health` answers **200** from **Vercel** with
`{"db":"not_configured"}`, and `https://0pt.app/api/0pt/sources` answers
**404** — the deployed build predates the entire `app/api/0pt/*` capability
layer. `https://api.0pt.app/health` answers **404** (the FastAPI engine is
not reachable), and `.gitea/workflows/cd.yml` still guards on
`COOLIFY_APP_ID = TODO-no-coolify-app-provisioned-yet`. So: none of the
capability layer — neither this pass's work nor P010/P014's — is running in
production, and P060's durability guarantee cannot be exercised there,
because production has no database configured and does not serve the routes.
Verified with `curl`; not inferred.
### P065 — closure record (2026-09-07)
The removal plan below was executed in full and is verified in CI:
1. ✅ Canonical ER exposed as `POST /api/0pt/er/candidates` (public,
tenant-safed) AND `POST /api/0pt/internal/er/candidates` (service-token,
fails closed without `X-Internal-Token`).
2. ✅ Python CI job: `.gitea/workflows/python-engine.yml` runs
`services/api/tests` as a BLOCKING pytest gate.
3. ✅ `workers/resolve.py` rewritten as the HTTP adapter — fail-closed to
UNMERGED records when the bridge is unreachable; no fallback scorer
remains. Cluster-wide hard-negative constraints prevent transitive
matches from overriding canonical identity decisions.
4. ✅ `services/api/tests/test_resolve_determinism.py` asserts the adapter
contract: canonical match edges merge; review/non-match edges do not;
hard negatives are respected transitively.
Residual honesty: the PYTHON-side tests run in CI, but the full
adapter→Next.js route chain has not been exercised against a live deployed
edge in this branch (the route's own fail-closed behavior is unit-proven).
That keeps P065 at INTEGRATION_TESTED, not LIVE_VERIFIED.
---
## SLUTPRODUKTIONS-VERIFIERING 2026-09-22 (live-mätt, ej påstått)
**Prod: `https://0pt.app` på flottan (server6, Coolify-app `shxd6lepxuxfsjsp0ath3z1g`),
container-image på exakt merge-SHA `df6ab70` via den signerade release-kedjan
(cosign-attestation verifierad före redeploy). DNS A → 65.109.19.146,
NS chris/maxine.ns.cloudflare.com — Vercel är borta.**
### Live-matris, mätt med curl (2026-09-22 04:09 UTC)
| Yta | Resultat |
|---|---|
| Front `/` + 10 nyckelsidor (bestall, katalog, kallor, tjanster, portal, admin, order) | 200 ×11 |
| `/api/health` | 200 `{ok:true, db:"ok", engine:"ok"}` |
| `/api/ready` | 200 (fail-closed-grinden: var 503 före DB-provisioneringen) |
| `/api/0pt/*` (9 rutter) anonymt | **401 ×9** — S1-luckan (2026-08-07: anonym 200) är STÄNGD |
| `/api/orders`, `/api/admin/metrics` anonymt | 401 |
| `/api/subscriptions` | 200 `[]` (var 500: migration 014, se nedan) |
| `POST /api/estimates` | 200 (edge räknar + engine-wire) |
| `GET /api/catalog`, `/api/sources` | 200 (publika läsvyter, avsiktligt) |
| `GET /api/vehicles` utan param | 400 (kräver reg-nr — korrekt) |
| Security headers | HSTS(63072000+preload) · CSP · XFO DENY · nosniff · ingen X-Powered-By |
### Denna pass fixat (3 PR, alla mergerade + deployade live)
1. **PR #80** — G17 hook-pin re-bump `7725498 → a401880` (re-review: 0 diff under
`ci/v2/reference-hooks/`). Release chain + gated deploy gröna igen.
2. **PR #81 (fix för issue #53)**: 7 legacy money-routes (orders, orders/[id],
subscriptions, payments/deposit, payments/release, webhooks/stripe,
admin/metrics) mappar nu `SiaxError(DEPENDENCY_UNAVAILABLE, 503, retryable)`
via `app/api/siax-error-mapping.ts` i stället för att läcka Next opaka 500.
Stripe-webhooken svarar 503 `received:false reason:store_unavailable` på
store-outage — aldrig tyst `received:true` för ett betalt event som inte
processats. 3 nya tester pinar kontraktet.
3. **PR #86**: `sql/014_subscriptions_created_at.sql` — `lib/subscriptions-store.ts`
SELECT:ade `created_at` som `001_init.sql` aldrig definierat (42703 mätt live:
GET /api/subscriptions har ALWAYS varit 500 mot Postgres). Kolonnen ADD:ad
idempotent LIVE på 0pt-edge-postgres; route svarar nu 200 `[]`.
4. **PR #87**: cd-gatens not-ready-regex — cosign 3.1.3 säger
`no matching attestations:` (utan `found`); en transient not-ready misstolkades
som hårt verify-fel. Fail-closed för riktiga attestfel oförändrad.
5. **CI-infra reparerad**: repo-secrets `SIAX_CI_TOKEN` + `REGISTRY_TOKEN` var
döda efter token-svepet (2026-09-22 eng0-introspect-rotation revoade 107+ tokens;
fällan: 0pt:s repo-secret överrider org-secreten — org-PUT räcker inte). Nya
siax-ci-tokens (`write:package,read:package` + all) mintade och satta via
secrets-API (PUT 204, verifierade via lyckad release-kedja).
### 0pt-motor (engine, satellite)
- 6 containers Up på server6 (`motor-provisional`), core `/health` ok+pg+redis,
delivery `/version` = fb67870, core revision = 6cfd433 — båda IN main.
- CI grön på main (runs 55985/55987/56004) — healer-issue #38 (run 40562,
2026-09-20) stängd som transient runner-flake (apt-GPG under last).
- Bara `main` kvar i 0pt och 0pt-motor (8 + 6 stale grenar raderade — alla
bevisade redan mergerade via `git merge-base --is-ancestor`).
- motor/-executor-koden (signals/NER/normalization, 3507 rader nya sedan 6cfd433)
konsumeras av `Dockerfile.motor`-imaget som ännu inte har ett driftsatt runtime
i stacken (core/ingest/resolve/delivery importerar ej motor/ — verifierat
import-graf). Byggs enligt `scripts/build-images.sh` + `verify-image.sh`-gaten
när executor-ytan aktiveras — dokumenterat i `docs/runbook/deploy.md`.
### Kvarstår (ärligt, ej gömt)
1. **Stripe-creds** (Simon): live-keys → edge-envs `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`.
Innan dess förblir payments 501 och issue #67:s staging-triage kan ej köras.
2. **queue: not_configured** — ingen Redis i edge-stacken (ärligt rapporterat).
3. **`0pt.siax.io`/`motor.siax.io`**: medvetet ej återupprättade (0pt.app + lokala
127.0.0.1-portar är kanoniska ytor; issues #70/#35 stängda/avgjorda därefter).
4. **motor.siax.io**: motor:s publika yta kräver Simon-beslut (traefik-labels + DNS).
-36
View File
@@ -1,36 +0,0 @@
{
"$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": []
}
-51
View File
@@ -1,51 +0,0 @@
# `analytics/dbt/` — BI-P022
The canonical SIAX transformation project. **Nothing in this repository runs
it.** Execution lives in N0D (`n0d#38`), against the contract in
`src/analytics/transform/dbt-contract.ts`.
## Layers, and why they map onto BI-P003
| directory | tier (BI-P003) | may read | must not contain |
| ---------------------- | -------------- | --------------------- | --------------------- |
| `models/sources/` | `RAW` | — (declarations only) | any SQL |
| `models/staging/` | `NORMALIZED` | sources | joins, business logic |
| `models/intermediate/` | `CURATED` | staging | presentation shaping |
| `models/marts/` | `SERVING` | intermediate | direct source reads |
`assertLayerDependenciesLegal` enforces the "may read" column against the
dependency graph in `manifest.json`. A mart reading a source directly is
refused — that is raw data reaching a dashboard, which is precisely what
BI-P003's tier rule exists to prevent, and a convention nobody checks would not
prevent it.
## What a run must produce
`manifest.json` and `run_results.json`, both registered as ST0RE artifacts.
`assertValidDbtJobResult` refuses a result missing either: without them the run
cannot be audited and its tests cannot be ingested by BI-P025's
`DbtQualityProvider`.
It also refuses:
- a result whose revision differs from the request — the run is not reproducible;
- a result whose observed dbt version differs from the pin — a runtime that is
not the pinned one silently changes what the models mean;
- `status: success` with **zero nodes executed** — a dbt selector that matches
nothing exits 0, which is the dbt-shaped false green.
## Version posture
`DBT_RUNTIME_PIN` records dbt Core 1.10.13 (stable line) with
`dbt-clickhouse` 1.9.2. **Both review flags are `false`.** No licence or
supply-chain review has been performed in this repo, and none is claimed.
`assertDbtRuntimeCertified` refuses a `production` run until they are true, so
BI-P022's "after current license/security verification" is a gate rather than a
sentence in a document.
## Credentials
A dbt run never receives a tenant's warehouse credential from this repo. N0D
resolves a credential whose write scope matches `target_layer` and
`expected_target_schema`, and the contract refuses a request whose expected
schema is not scoped to the job's tenant.
-43
View File
@@ -1,43 +0,0 @@
# BI-P022 — the canonical SIAX analytics transformation project.
#
# Execution happens in N0D (n0d#38), never here. This project is the MODELS and
# the CONVENTIONS; src/analytics/transform/dbt-contract.ts is the job contract
# N0D consumes, and it is what refuses a run that is not reproducible.
name: siax_analytics
version: 1.0.0
config-version: 2
profile: siax_analytics
model-paths: ['models']
test-paths: ['tests']
snapshot-paths: ['snapshots']
macro-paths: ['macros']
# The four layers map onto BI-P003's tiers rather than inventing a fifth
# vocabulary. `+tags` carries the tier so a manifest reader can assign one
# without parsing paths, and dbt-contract.ts asserts the mapping.
models:
siax_analytics:
+materialized: view
staging:
+schema: staging
+materialized: view
+tags: ['tier:NORMALIZED']
intermediate:
+schema: intermediate
+materialized: table
+tags: ['tier:CURATED']
marts:
+schema: marts
+materialized: table
+tags: ['tier:SERVING']
snapshots:
siax_analytics:
+target_schema: snapshots
# `on_schema_change: fail` rather than `sync_all_columns`: a silently widened
# table is how a downstream metric starts averaging a column that did not exist
# yesterday. BI-P004's contract gate is the same rule one layer up.
vars:
siax_tenant_id: '__set_by_n0d__'
@@ -1,16 +0,0 @@
-- CURATED (BI-P003). Joins and conformed entities. Reads STAGING only.
{{ config(materialized='table', tags=['tier:CURATED']) }}
select
e.event_id,
e.tenant_id,
e.customer_id,
e.amount_sek,
e.channel,
coalesce(c.country_code, e.country_code) as country_code,
c.segment,
e.invoiced_at
from {{ ref('stg_sales_events') }} e
left join {{ ref('stg_customers') }} c
on e.customer_id = c.customer_id
and e.tenant_id = c.tenant_id -- the join itself stays tenant-scoped
@@ -1,17 +0,0 @@
-- SERVING (BI-P003). What a dashboard or a BI-P028 metric reads.
--
-- Reads INTERMEDIATE only. Reading a source here would skip two layers, and
-- assertLayerDependenciesLegal refuses that against the manifest graph.
{{ config(materialized='table', tags=['tier:SERVING']) }}
select
tenant_id,
date_trunc('day', invoiced_at) as day,
country_code,
channel,
segment,
count(*) as order_count,
sum(amount_sek) as net_revenue_sek
from {{ ref('int_sales_enriched') }}
where channel != 'internal' -- matches the net_revenue metric's baked filter
group by 1, 2, 3, 4, 5
-19
View File
@@ -1,19 +0,0 @@
version: 2
models:
- name: mart_sales_daily
description: >
SERVING projection behind the `net_revenue` and `orders` metrics.
A blocking test failure here quarantines the serving projection
(BI-P025); the quarantine is enforced on the READ side.
columns:
- name: tenant_id
tests: [not_null]
- name: day
tests: [not_null]
- name: net_revenue_sek
tests: [not_null]
- name: order_count
tests:
- not_null
- dbt_utils.accepted_range: { min_value: 1, inclusive: true }
-34
View File
@@ -1,34 +0,0 @@
version: 2
# RAW. Declared, never modelled. A source has no SQL of its own — anything that
# transforms belongs in staging, where BI-P003 assigns it NORMALIZED.
sources:
- name: opt_raw
description: >
Landed, untransformed data as delivered by the 0pt sourcing engine.
RAW tier: no BI surface may read this directly without a recorded
BI-P003 exception.
schema: "{{ var('siax_tenant_id') }}_raw"
loaded_at_field: ingested_at
freshness:
# BI-P026's DELAYED/STALE thresholds, expressed where dbt can act on them.
warn_after: { count: 90, period: minute }
error_after: { count: 180, period: minute }
tables:
- name: sales_events
description: One row per invoiced sales line, as landed.
columns:
- name: event_id
tests: [not_null, unique]
- name: tenant_id
description: Present on every raw row; the tier boundary depends on it.
tests: [not_null]
- name: ingested_at
tests: [not_null]
- name: customers
description: Customer master as landed.
columns:
- name: customer_id
tests: [not_null, unique]
- name: tenant_id
tests: [not_null]
-26
View File
@@ -1,26 +0,0 @@
version: 2
models:
- name: stg_sales_events
description: Normalized sales events — one row per event_id, latest landing wins.
columns:
- name: event_id
tests: [not_null, unique]
- name: tenant_id
tests: [not_null]
- name: amount_sek
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
inclusive: true
- name: channel
tests:
- accepted_values:
values: ['web', 'retail', 'partner', 'internal']
- name: stg_customers
columns:
- name: customer_id
tests: [not_null, unique]
- name: tenant_id
tests: [not_null]
@@ -1,11 +0,0 @@
-- NORMALIZED (BI-P003).
{{ config(materialized='view', tags=['tier:NORMALIZED']) }}
select
cast(customer_id as varchar) as customer_id,
cast(tenant_id as varchar) as tenant_id,
cast(name as varchar) as customer_name,
cast(segment as varchar) as segment,
cast(country as varchar) as country_code
from {{ source('opt_raw', 'customers') }}
where tenant_id = '{{ var("siax_tenant_id") }}'
@@ -1,30 +0,0 @@
-- NORMALIZED (BI-P003). Rename, cast, deduplicate. No joins, no business logic.
--
-- The tenant predicate is applied HERE, at the first modelled layer, so every
-- downstream model inherits it and no later model has to remember. It is the
-- same forced-predicate rule as the query compiler's, one layer up.
{{ config(materialized='view', tags=['tier:NORMALIZED']) }}
with source as (
select * from {{ source('opt_raw', 'sales_events') }}
where tenant_id = '{{ var("siax_tenant_id") }}'
),
deduplicated as (
select
*,
row_number() over (partition by event_id order by ingested_at desc) as _rn
from source
)
select
cast(event_id as varchar) as event_id,
cast(tenant_id as varchar) as tenant_id,
cast(customer_id as varchar) as customer_id,
cast(amount as double) as amount_sek,
cast(channel as varchar) as channel,
cast(country as varchar) as country_code,
cast(invoiced_at as timestamp) as invoiced_at,
cast(ingested_at as timestamp) as ingested_at
from deduplicated
where _rn = 1
@@ -1,15 +0,0 @@
-- Slowly-changing customer attributes, so a historical metric can be
-- recomputed against what a customer's segment WAS, not what it is now.
{% snapshot snap_customers %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='check',
check_cols=['segment', 'country_code'],
invalidate_hard_deletes=True
)
}}
select customer_id, tenant_id, customer_name, segment, country_code
from {{ ref('stg_customers') }}
{% endsnapshot %}
@@ -1,7 +0,0 @@
-- Fails when the serving mart is EMPTY.
--
-- Without this, every column test passes trivially over zero rows and the
-- whole suite reports green while the pipeline is dead — the same false green
-- BI-P025's NativeQualityProvider refuses.
select 1 as empty_mart
having (select count(*) from {{ ref('mart_sales_daily') }}) = 0
@@ -1,11 +0,0 @@
-- A singular test that FAILS if the serving mart ever contains a row belonging
-- to a tenant other than the one the run was scoped to.
--
-- The tenant predicate is applied in staging and inherited downstream, so this
-- test should be impossible to fail. That is precisely why it exists: an
-- isolation guarantee nobody tests is the one that turns out to have four
-- holes rather than the one reported.
select tenant_id, count(*) as rows_from_another_tenant
from {{ ref('mart_sales_daily') }}
where tenant_id != '{{ var("siax_tenant_id") }}'
group by tenant_id
-142
View File
@@ -1,142 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useT } from "@/components/locale-provider";
import { PortalShell } from "@/components/portal-shell";
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
import { formatSEK } from "@/lib/data";
// Admin · Kunder. No auth/DB customer table yet, so we derive a customer list
// from GET /api/orders, grouping by flow as a stand-in for organisation.
// Each group: name, antal ordrar, summa (sum of total_ore → kr).
interface Order {
order_id: string;
flow: string;
total_ore: number;
created_at: string;
}
interface CustomerRow {
name: string;
orders: number;
totalOre: number;
last: string;
}
function groupByFlow(orders: Order[]): CustomerRow[] {
const map = new Map<string, CustomerRow>();
for (const o of orders) {
const name = o.flow || "unknown";
const row = map.get(name) ?? { name, orders: 0, totalOre: 0, last: "" };
row.orders += 1;
row.totalOre += o.total_ore ?? 0;
if (o.created_at > row.last) row.last = o.created_at;
map.set(name, row);
}
return [...map.values()].sort((a, b) => b.totalOre - a.totalOre);
}
function fmtDate(iso: string): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toISOString().slice(0, 10);
}
export default function AdminCustomersPage() {
const t = useT();
const [rows, setRows] = useState<CustomerRow[] | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
setRows(null);
try {
const r = await fetch("/api/orders", { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const data: Order[] = await r.json();
setRows(groupByFlow(Array.isArray(data) ? data : []));
} catch {
setError(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const totalOre = rows?.reduce((s, r) => s + r.totalOre, 0) ?? 0;
return (
<PortalShell variant="admin" title={t({ en: "Customers", sv: "Kunder" })}>
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
{t({ en: "Derived from orders. Grouped by flow. Organisation linking at auth.", sv: "Härlett ur ordrar. Grupperat per flöde. Org-koppling vid auth." })}
</p>
{error ? (
<div className="mt-6">
<ErrorState onRetry={load} />
</div>
) : !rows ? (
<div className="mt-6">
<LoadingSkeleton rows={6} />
</div>
) : rows.length === 0 ? (
<div className="mt-6">
<EmptyState
title={t({ en: "No customers yet", sv: "Inga kunder ännu" })}
line={t({ en: "Customers appear here as soon as the first order is created.", sv: "Kunder visas här så fort första ordern skapas." })}
ctaHref="/bestall"
ctaLabel={t({ en: "New order", sv: "Ny beställning" })}
/>
</div>
) : (
<>
<div className="mt-6 flex flex-wrap items-baseline gap-x-8 gap-y-2 border border-border bg-secondary/40 px-4 py-3">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{rows.length} {t({ en: "customers", sv: "kunder" })}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{rows.reduce((s, r) => s + r.orders, 0)} {t({ en: "orders", sv: "ordrar" })}
</span>
<span className="ml-auto font-mono text-sm tabular-nums">
{formatSEK(totalOre / 100)}
</span>
</div>
<div className="mt-px border border-border">
<div className="grid grid-cols-[2fr_0.8fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>{t({ en: "Customer", sv: "Kund" })}</span>
<span className="text-right">{t({ en: "Orders", sv: "Ordrar" })}</span>
<span className="text-right">{t({ en: "Latest", sv: "Senast" })}</span>
<span className="text-right">{t({ en: "Total", sv: "Summa" })}</span>
</div>
{rows.map((c) => (
<div
key={c.name}
className="grid grid-cols-[2fr_0.8fr_1fr_1fr] items-center border-b border-border px-4 py-3.5 text-sm last:border-b-0"
>
<span className="flex items-center gap-2.5 font-medium">
<span
aria-hidden
className="inline-block h-2 w-2 shrink-0"
style={{ backgroundColor: "var(--prov-2)" }}
/>
<span className="truncate">{c.name === "unknown" ? t({ en: "Unknown", sv: "Okänd" }) : c.name}</span>
</span>
<span className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{c.orders}
</span>
<span className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{fmtDate(c.last)}
</span>
<span className="text-right font-mono text-sm tabular-nums">
{formatSEK(c.totalOre / 100)}
</span>
</div>
))}
</div>
</>
)}
</PortalShell>
);
}
-223
View File
@@ -1,223 +0,0 @@
"use client";
// C8: "Importera ordrar" — the first real product host of the @siax/ui
// ImportWizard + @siax/import-export runtime. The wizard renders the frozen
// store snapshot; THIS host owns every pipeline step's execution and all
// server I/O. The server (POST /api/orders/import) is the only authority:
// it re-parses and re-validates the raw CSV, so the client-side parse below
// exists purely to give the wizard rows to render.
import { ImportWizard } from "@siax/ui";
import { createImportRunStore, type ImportRunStore, type ImportRunSnapshot } from "@siax/import-export";
import { createImportJob } from "@siax/contracts/import-export";
import { useCallback, useState } from "react";
const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const;
interface ServerReport {
imported?: number;
totalRows?: number;
validRows?: number;
errorCount?: number;
errors?: { row: number; field: string; code: string; message?: string }[];
order_ids?: string[];
}
/** Client-side RFC 4180 parse — presentation only; the server re-parses the raw CSV. */
function clientParse(text: string): Record<string, string>[] {
const rows: string[][] = [];
let field = "";
let row: string[] = [];
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else inQuotes = false;
} else field += ch;
} else if (ch === '"') inQuotes = true;
else if (ch === ",") {
row.push(field);
field = "";
} else if (ch === "\n") {
row.push(field);
field = "";
rows.push(row);
row = [];
} else if (ch !== "\r") field += ch;
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
if (nonEmpty.length === 0) return [];
const header = nonEmpty[0].map((h) => h.trim().toLowerCase());
return nonEmpty.slice(1).map((cells) => {
const obj: Record<string, string> = {};
header.forEach((h, idx) => {
if (h) obj[h] = (cells[idx] ?? "").trim();
});
return obj;
});
}
async function postImport(mode: "validate" | "dry_run" | "execute", csv: string): Promise<{ ok: boolean; status: number; data: ServerReport & { error?: { message?: string } } }> {
const res = await fetch("/api/orders/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, csv }),
});
let data: ServerReport & { error?: { message?: string } } = {};
try {
data = await res.json();
} catch {
data = { error: { message: `HTTP ${res.status}` } };
}
return { ok: res.ok, status: res.status, data };
}
export default function AdminImportPage() {
const [rawCsv, setRawCsv] = useState<string | null>(null);
const [store, setStore] = useState<ImportRunStore | null>(null);
const [serverReport, setServerReport] = useState<ServerReport | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const [recentOrders, setRecentOrders] = useState<{ order_id: string; flow: string; status: string }[] | null>(null);
const onFile = useCallback(async (file: File) => {
const text = await file.text();
setRawCsv(text);
setServerReport(null);
setServerError(null);
setRecentOrders(null);
const rows = clientParse(text);
const s = createImportRunStore({
job: createImportJob({
id: globalThis.crypto?.randomUUID?.() ?? `imp-${Date.now()}`,
tenantId: "current",
entity: "orders",
format: "csv",
source: { system: "0pt-admin", channel: "web" },
createdAt: new Date().toISOString(),
}),
rows,
});
s.detect();
setStore(s);
}, []);
const onValidate = useCallback(async () => {
if (!store || !rawCsv) return;
setServerError(null);
const { ok, status, data } = await postImport("validate", rawCsv);
setServerReport(data);
store.validate(); // local snapshot for the wizard report (same frozen kernel)
if (!ok) setServerError(`Servervalidering avvisade filen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
}, [store, rawCsv]);
const onDryRun = useCallback(async () => {
if (!store || !rawCsv) return;
setServerError(null);
const { ok, status, data } = await postImport("dry_run", rawCsv);
setServerReport(data);
store.dryRun();
if (!ok) setServerError(`Servern nekade generalrepet (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
}, [store, rawCsv]);
const onExecute = useCallback(async () => {
if (!store || !rawCsv) return;
setServerError(null);
const { ok, status, data } = await postImport("execute", rawCsv);
setServerReport(data);
if (!ok) {
// Server is the authority — a failed execute is terminal for this run.
store.fail(`Servern avvisade exekveringen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
return;
}
// Host executor proxies the server result into the store's hard gates.
const snapshot: ImportRunSnapshot = await store.execute(async () => ({ imported: data.imported ?? 0 }));
// Reload-persistence proof: re-read the REAL order list after import.
try {
const res = await fetch("/api/orders", { cache: "no-store" });
if (res.ok) {
const orders = (await res.json()) as { order_id: string; flow: string; status: string }[];
const importedIds = new Set(data.order_ids ?? []);
setRecentOrders(orders.filter((o) => importedIds.has(o.order_id)));
}
} catch {
/* panel stays null — server report above still shows the truth */
}
void snapshot;
}, [store, rawCsv]);
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>Importera ordrar</h1>
<p style={{ marginBottom: "1rem", opacity: 0.8 }}>
CSV med kolumnerna: {IMPORT_COLUMNS.join(", ")}. Endast <code>flow</code> är obligatorisk
(catalog | custom | subscription). Parsering och validering sker på servern —
filen laddas inte upp förrän ett steg körs.
</p>
<input
type="file"
accept=".csv,text/csv"
aria-label="Välj CSV-fil"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void onFile(f);
}}
/>
{store && (
<div style={{ marginTop: "1.5rem" }}>
<ImportWizard
store={store}
columns={IMPORT_COLUMNS.map((c) => ({ source: c, target: c }))}
onMap={() => {
store.map({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) });
}}
onValidate={onValidate}
onDryRun={onDryRun}
onExecute={onExecute}
/>
</div>
)}
{serverError && (
<p role="alert" style={{ marginTop: "1rem", color: "crimson" }}>
{serverError}
</p>
)}
{serverReport && (serverReport.errorCount ?? 0) > 0 && (
<section style={{ marginTop: "1.5rem" }} aria-label="Felrapport från servern">
<h2 style={{ fontSize: "1.1rem" }}>Felrader (serverns rapport)</h2>
<ul>
{(serverReport.errors ?? []).map((e, i) => (
<li key={i}>
Rad {e.row} · {e.field} · {e.code}
{e.message ? ` — ${e.message}` : ""}
</li>
))}
</ul>
</section>
)}
{recentOrders && (
<section style={{ marginTop: "1.5rem" }} aria-label="Importerade ordrar efter reload">
<h2 style={{ fontSize: "1.1rem" }}>Importerade ordrar (lästa från /api/orders efter import)</h2>
{recentOrders.length === 0 ? (
<p>Inga importerade ordrar hittades vid reload.</p>
) : (
<ul>
{recentOrders.map((o) => (
<li key={o.order_id}>
<code>{o.order_id}</code> — {o.flow} — {o.status}
</li>
))}
</ul>
)}
</section>
)}
</main>
);
}
-216
View File
@@ -1,216 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PortalShell, StatusPill } from "@/components/portal-shell";
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
import { formatSEK } from "@/lib/data";
import { useT } from "@/components/locale-provider";
// Admin pipeline monitor. Reads GET /api/orders, keeps the rows whose status is
// in_production/review, and renders each as a live pipeline row: the five engine
// stages as hairline progress chips + a StatusPill. No fetching of stage data
// exists yet, so per-stage progress is derived deterministically from the order
// id — a stable placeholder until the orchestrator streams real stage events.
interface OrderConfig {
markets?: string[];
type?: string;
volume?: number;
}
interface Order {
order_id: string;
flow: string;
status: string;
config: OrderConfig;
total_ore: number;
est_confidence: number;
created_at: string;
}
const STAGES = [
"Källracing",
"Extraktion & OCR",
"Strukturering",
"Berikning & fusion",
"Kvalitetskontroll",
] as const;
const RUNNING = new Set(["in_production", "review"]);
// Deterministic placeholder progress per order, 0..1 across the 5 stages.
// "review" orders sit at the QA stage; in_production progress fans out.
function stageProgress(order: Order): number[] {
let h = 0;
for (const ch of order.order_id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const front = order.status === "review" ? 4 : h % STAGES.length;
return STAGES.map((_, i) => {
if (i < front) return 1;
if (i > front) return 0;
return order.status === "review" ? 0.62 : 0.25 + ((h >> (i + 3)) % 60) / 100;
});
}
const STAGE_LABELS_EN: Record<(typeof STAGES)[number], string> = {
"Källracing": "Source racing",
"Extraktion & OCR": "Extraction & OCR",
"Strukturering": "Structuring",
"Berikning & fusion": "Enrichment & fusion",
"Kvalitetskontroll": "Quality control",
};
function PipelineRow({ order }: { order: Order }) {
const t = useT();
const progress = stageProgress(order);
const markets = order.config.markets ?? [];
const done = progress.filter((p) => p >= 1).length;
return (
<div className="border-b border-border px-4 py-4 last:border-b-0">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div className="flex items-baseline gap-3">
<span className="font-mono text-xs text-muted-foreground">
{order.order_id.slice(0, 8)}
</span>
<span className="text-sm font-medium">{order.config.type ?? t({ en: "Company", sv: "Bolag" })}</span>
<span className="font-mono text-[11px] text-muted-foreground">
{markets.length ? markets.join(" · ") : "—"}
</span>
</div>
<div className="flex items-center gap-5">
<span className="font-mono text-[11px] text-muted-foreground">
{(order.config.volume ?? 0).toLocaleString("sv-SE")} {t({ en: "rows", sv: "rader" })}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{formatSEK(order.total_ore / 100)}
</span>
<StatusPill status={order.status} />
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{STAGES.map((stage, i) => {
const p = progress[i];
const active = p > 0 && p < 1;
const complete = p >= 1;
return (
<div key={stage} className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-[9.5px] uppercase tracking-[0.1em] text-muted-foreground">
{t({ en: STAGE_LABELS_EN[stage], sv: stage })}
</span>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 shrink-0"
style={{
backgroundColor: complete
? "var(--prov-3)"
: active
? "var(--accent-red)"
: "var(--border)",
}}
/>
</div>
<div
className="mt-1.5 h-1 w-full border border-border bg-background"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(p * 100)}
aria-label={`${t({ en: STAGE_LABELS_EN[stage], sv: stage })}: ${Math.round(p * 100)}%`}
>
<span
className="block h-full"
style={{
width: `${Math.round(p * 100)}%`,
backgroundColor: complete ? "var(--prov-3)" : "var(--accent-red)",
}}
/>
</div>
</div>
);
})}
</div>
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{done}/{STAGES.length} {t({ en: "steps · started", sv: "steg · startad" })} {new Date(order.created_at).toLocaleDateString("sv-SE")}
</p>
</div>
);
}
export default function AdminJobsPage() {
const t = useT();
const [orders, setOrders] = useState<Order[] | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
setOrders(null);
try {
const r = await fetch("/api/orders", { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const data = (await r.json()) as Order[];
setOrders(Array.isArray(data) ? data : []);
} catch {
setError(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const running = (orders ?? []).filter((o) => RUNNING.has(o.status));
return (
<PortalShell variant="admin" title={t({ en: "Jobs & pipeline", sv: "Jobb & pipeline" })}>
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
{t({
en: "Active production. Five stages per job. Escrow releases on approval.",
sv: "Aktiv produktion. Fem steg per jobb. Escrow släpps vid godkänt.",
})}
</p>
{error ? (
<div className="mt-6">
<ErrorState onRetry={load} />
</div>
) : !orders ? (
<div className="mt-6">
<LoadingSkeleton rows={6} />
</div>
) : running.length === 0 ? (
<div className="mt-6">
<EmptyState
title={t({ en: "No jobs in production", sv: "Inga jobb i produktion" })}
line={t({
en: "When an order is paid the pipeline starts here — source racing through quality control in real time.",
sv: "När en order betalats startar pipelinen här — källracing till kvalitetskontroll i realtid.",
})}
ctaHref="/admin"
ctaLabel={t({ en: "To overview", sv: "Till översikt" })}
/>
</div>
) : (
<div className="mt-6">
<div className="flex items-center justify-between border border-b-0 border-border bg-secondary/50 px-4 py-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{running.length} {t({ en: "active jobs", sv: "aktiva jobb" })}
</span>
<button
type="button"
onClick={load}
className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{t({ en: "Refresh", sv: "Uppdatera" })}
</button>
</div>
<div className="border border-border">
{running.map((o) => (
<PipelineRow key={o.order_id} order={o} />
))}
</div>
</div>
)}
</PortalShell>
);
}
-238
View File
@@ -1,238 +0,0 @@
"use client";
// Admin-yta: anteckningar på order — @siax/collaboration-kernelns första
// produktkonsument (spår C). Allt nätverk går via lib/order-notes-client.ts
// (kernel-transport över host-API): offline-läge = medvetet no-op med varning
// (utkastet bevaras, ingen tyst förlust), stale skrivning = 409 -> banner +
// reload av serverns aktuella version.
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { PortalShell } from "@/components/portal-shell";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/states";
import {
ConflictError,
OfflineError,
OrderNotesClient,
type OrderNoteDto,
} from "@/lib/order-notes-client";
interface Order {
order_id: string;
flow: string;
status: string;
created_at: string;
}
export default function OrderNotesPage() {
const [orders, setOrders] = useState<Order[] | null>(null);
const [orderId, setOrderId] = useState<string>("");
const [notes, setNotes] = useState<OrderNoteDto[] | null>(null);
const [loadError, setLoadError] = useState(false);
const [offlineMode, setOfflineMode] = useState(false);
const [draft, setDraft] = useState("");
const [editing, setEditing] = useState<{ note: OrderNoteDto; body: string } | null>(null);
const client = useMemo(() => new OrderNotesClient("admin-simon"), []);
useEffect(() => {
client.transport.setOffline(offlineMode);
}, [client, offlineMode]);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/orders", { cache: "no-store" });
if (!res.ok) throw new Error("orders fetch failed");
const body = await res.json();
setOrders(Array.isArray(body) ? body : []);
} catch {
setLoadError(true);
}
})();
}, []);
const loadNotes = useCallback(
async (id: string) => {
setNotes(null);
setLoadError(false);
try {
const { notes: loaded } = await client.load(id);
setNotes(loaded);
} catch {
setLoadError(true);
}
},
[client],
);
const addNote = useCallback(async () => {
if (!orderId || !draft.trim()) return;
if (offlineMode) {
// Offline = klient-varning + no-op. Utkastet ligger kvar i rutan —
// INGET skickas, inget försvinner tyst.
toast.warning("Offline-läge: anteckningen SKICKAS EJ. Utkastet är bevarat i rutan.");
return;
}
try {
const note = await client.add(orderId, draft);
setDraft("");
setNotes((prev) => [...(prev ?? []), note]);
toast.success("Anteckning sparad");
} catch (err) {
if (err instanceof OfflineError) {
toast.warning("Offline: inget skickades — utkastet är bevarat.");
return;
}
setLoadError(true);
}
}, [client, draft, offlineMode, orderId]);
const saveEdit = useCallback(async () => {
if (!editing) return;
if (offlineMode) {
toast.warning("Offline-läge: ändringen SKICKAS EJ. Texten är bevarad i redigeringsrutan.");
return;
}
try {
const updated = await client.update(editing.note, editing.body, editing.note.version);
setNotes((prev) => (prev ?? []).map((n) => (n.note_id === updated.note_id ? updated : n)));
setEditing(null);
toast.success("Anteckning uppdaterad");
} catch (err) {
if (err instanceof ConflictError) {
// Någon annan skrev samtidigt (stale version -> 409). Ladda om
// serverns aktuella version — förlora ALDRIG den lokala texten tyst.
toast.error(`Konflikt: anteckningen ändrades av någon annan (serverversion ${err.currentVersion}). Laddar om — din text finns kvar nedan.`);
setDraft((prev) => (prev ? prev : editing.body));
await loadNotes(orderId);
setEditing(null);
return;
}
if (err instanceof OfflineError) {
toast.warning("Offline: inget skickades — ändringen är bevarad.");
return;
}
setLoadError(true);
}
}, [client, editing, loadNotes, offlineMode, orderId]);
return (
<PortalShell variant="admin" title="Orderanteckningar">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={offlineMode}
onChange={(e) => setOfflineMode(e.target.checked)}
/>
Offline-läge (blockera utskick — varning + no-op, utkast bevaras)
</label>
{offlineMode && (
<div role="alert" className="rounded border border-amber-400 bg-amber-50 p-2 text-sm">
Offline-läge aktivt: inga anteckningar skickas till servern. Utkast bevaras lokalt —
ingen tyst förlust.
</div>
)}
{loadError ? (
<ErrorState onRetry={() => orderId && loadNotes(orderId)} />
) : orders === null ? (
<LoadingSkeleton />
) : orders.length === 0 ? (
<EmptyState title="Inga ordrar" line="Inga ordrar att annotera ännu." />
) : (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm">
Order:{" "}
<select
aria-label="Välj order"
value={orderId}
onChange={(e) => {
setOrderId(e.target.value);
void loadNotes(e.target.value);
}}
className="rounded border px-2 py-1"
>
<option value="">— välj —</option>
{orders.map((o) => (
<option key={o.order_id} value={o.order_id}>
{o.order_id} ({o.flow}, {o.status})
</option>
))}
</select>
</label>
{orderId === "" ? (
<EmptyState title="Välj order" line="Välj en order för att se och skriva anteckningar." />
) : notes === null ? (
<LoadingSkeleton />
) : notes.length === 0 ? (
<EmptyState title="Tomt" line="Inga anteckningar på den här ordern ännu." />
) : (
<ul className="space-y-2">
{notes.map((n) => (
<li key={n.note_id} className="rounded border p-3 text-sm">
<div className="mb-1 flex justify-between text-xs opacity-70">
<span>
{n.author_id} · v{n.version} · klocka {n.clock}
</span>
<span>{new Date(n.updated_at).toLocaleString("sv-SE")}</span>
</div>
{editing?.note.note_id === n.note_id ? (
<div className="space-y-2">
<textarea
aria-label="Redigera anteckning"
value={editing.body}
onChange={(e) => setEditing({ ...editing, body: e.target.value })}
className="w-full rounded border p-2"
rows={3}
/>
<div className="flex gap-2">
<button className="rounded bg-black px-3 py-1 text-white" onClick={() => void saveEdit()}>
Spara (v{editing.note.version} → v{editing.note.version + 1})
</button>
<button className="rounded border px-3 py-1" onClick={() => setEditing(null)}>
Avbryt
</button>
</div>
</div>
) : (
<>
<p className="whitespace-pre-wrap">{n.body}</p>
<button
className="mt-2 text-xs underline"
onClick={() => setEditing({ note: n, body: n.body })}
>
Redigera
</button>
</>
)}
</li>
))}
</ul>
)}
{orderId !== "" && (
<div className="space-y-2">
<textarea
aria-label="Ny anteckning"
placeholder="Skriv en anteckning på ordern…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="w-full rounded border p-2"
rows={3}
/>
<button
className="rounded bg-black px-4 py-2 text-sm text-white disabled:opacity-40"
disabled={!draft.trim() || notes === null}
onClick={() => void addNote()}
>
Lägg till anteckning
</button>
</div>
)}
</div>
)}
</PortalShell>
);
}
-155
View File
@@ -1,155 +0,0 @@
"use client";
import { ActionGate, InboxList } from "@siax/ui";
import { nextOrderStatus } from "@/lib/order-flow";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { useT } from "@/components/locale-provider";
import { PortalShell, StatusPill } from "@/components/portal-shell";
import { LoadingSkeleton, ErrorState, EmptyState } from "@/components/states";
import { formatSEK } from "@/lib/data";
import { mergeOrders } from "@/lib/order-client";
interface Order {
order_id: string;
flow: string;
status: string;
total_ore: number;
deposit_ore: number;
created_at: string;
}
interface Metrics {
total: number;
in_production: number;
review: number;
delivered: number;
deposit_paid_ore: number;
pipeline_ore: number;
}
export default function AdminPage() {
const t = useT();
const [orders, setOrders] = useState<Order[] | null>(null);
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
try {
const [oRes, mRes] = await Promise.all([
fetch("/api/orders", { cache: "no-store" }),
fetch("/api/admin/metrics", { cache: "no-store" }),
]);
if (!oRes.ok || !mRes.ok) throw new Error("fetch failed");
const [o, m] = await Promise.all([oRes.json(), mRes.json()]);
if (!m || typeof m.total !== "number") throw new Error("bad metrics shape");
setOrders(mergeOrders(Array.isArray(o) ? o : []) as Order[]);
setMetrics(m);
} catch {
setError(true);
}
}, []);
useEffect(() => { void load(); }, [load]);
async function advance(id: string, status: string) {
const next = nextOrderStatus(status);
if (!next) return;
try {
const r = await fetch(`/api/orders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }) });
if (r.ok) {
toast.success(`${id.slice(0, 8)}… → ${next}`);
void load();
} else {
toast.error(t({ en: "Could not advance order.", sv: "Kunde inte flytta fram ordern." }));
}
} catch {
toast.error(t({ en: "Network error.", sv: "Nätverksfel." }));
}
}
return (
<PortalShell variant="admin" title={t({ en: "Order queue & operations", sv: "Orderkö & drift" })}>
{error ? (
<ErrorState onRetry={load} />
) : !metrics || !orders ? (
<LoadingSkeleton rows={6} />
) : (
<>
<div className="grid grid-cols-2 gap-px border border-border bg-border md:grid-cols-5">
{[
[t({ en: "Orders", sv: "Ordrar" }), String(metrics.total)],
[t({ en: "In production", sv: "I produktion" }), String(metrics.in_production)],
[t({ en: "In review", sv: "Granskas" }), String(metrics.review)],
[t({ en: "Delivered", sv: "Levererade" }), String(metrics.delivered)],
[t({ en: "Deposits in", sv: "Förskott in" }), formatSEK((metrics.deposit_paid_ore || 0) / 100)],
].map(([l, v]) => (
<div key={l} className="bg-background p-5">
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{l}</p>
<p className="mt-2 text-2xl font-medium tabular-nums tracking-[-0.02em]">{v}</p>
</div>
))}
</div>
{orders.some((o) => o.inbox_item) ? (
<section className="mt-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Inbox", sv: "Inkorg" })}</h2>
<div className="mt-4">
<InboxList
items={orders.map((o) => o.inbox_item).filter(Boolean)}
labels={{
resolve: t({ en: "Advance", sv: "Flytta fram" }),
emptyTitle: t({ en: "Inbox is empty", sv: "Inkorgen är tom" }),
}}
onResolve={(item) => {
const order = orders.find((o) => `order:${o.order_id}` === item.id);
if (order?.advance_action?.state === "available" && nextOrderStatus(order.status)) {
advance(order.order_id, order.status);
}
}}
/>
</div>
</section>
) : null}
<section className="mt-10">
<div className="flex items-center justify-between">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Order queue", sv: "Orderkö" })}</h2>
{/* DoD §65: real anchor, not a JS-handler button — the download is a plain GET of the CSV endpoint. */}
<a href="/api/orders/export" download className="border border-border px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:bg-foreground hover:text-background">
{t({ en: "Export CSV", sv: "Exportera CSV" })}
</a>
</div>
{orders.length === 0 ? (
<div className="mt-6"><EmptyState title={t({ en: "Empty queue.", sv: "Tom kö." })} line={t({ en: "No orders yet.", sv: "Inga ordrar än." })} /></div>
) : (
<div className="mt-4 border border-border">
<div className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>{t({ en: "ID", sv: "ID" })}</span><span>{t({ en: "Flow", sv: "Flöde" })}</span><span>{t({ en: "Status", sv: "Status" })}</span><span className="text-right">{t({ en: "Total", sv: "Summa" })}</span><span className="text-right">{t({ en: "Action", sv: "Åtgärd" })}</span>
</div>
{orders.map((o) => (
<div key={o.order_id} className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] items-center border-b border-border px-4 py-3 last:border-b-0">
<span className="font-mono text-[12px] tabular-nums">{o.order_id.slice(0, 8)}…</span>
<span className="text-sm">{o.flow}</span>
<StatusPill status={o.status} />
<span className="text-right font-mono text-[12px] tabular-nums">{formatSEK(o.total_ore / 100)}</span>
<span className="text-right">
<ActionGate action={o.advance_action} className="inline-block text-right">
<button
onClick={() => advance(o.order_id, o.status)}
className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background"
>
→ {nextOrderStatus(o.status) ?? t({ en: "done", sv: "klar" })}
</button>
</ActionGate>
</span>
</div>
))}
</div>
)}
</section>
</>
)}
</PortalShell>
);
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useT } from "@/components/locale-provider";
import { PortalShell } from "@/components/portal-shell";
import { LoadingSkeleton, ErrorState } from "@/components/states";
interface Source {
source_id: string;
name: string;
market: string;
access: string;
licence: string;
cadence: string;
confidence: number;
}
const ACCESS_COLOR: Record<string, string> = {
api: "var(--prov-3)", bulk: "var(--prov-2)", authorized_web: "var(--prov-1)", licensed: "var(--prov-0)",
};
export default function AdminSourcesPage() {
const t = useT();
const [sources, setSources] = useState<Source[] | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
setError(false);
try {
const r = await fetch("/api/sources", { cache: "no-store" });
setSources(await r.json());
} catch { setError(true); }
}, []);
useEffect(() => { void load(); }, [load]);
return (
<PortalShell variant="admin" title={t({ en: "Source registry & freshness", sv: "Källregister & färskhet" })}>
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
{t({ en: "Legal sourcing. API > bulk > authorized web > licence. Verify terms.", sv: "Laglig sourcing. API > bulk > auktoriserad webb > licens. Verifiera villkor." })}
</p>
{error ? (
<div className="mt-6"><ErrorState onRetry={load} /></div>
) : !sources ? (
<div className="mt-6"><LoadingSkeleton rows={8} /></div>
) : (
<div className="mt-6 border border-border">
<div className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>{t({ en: "Source", sv: "Källa" })}</span><span>{t({ en: "Market", sv: "Marknad" })}</span><span>{t({ en: "Access", sv: "Åtkomst" })}</span><span>{t({ en: "Licence", sv: "Licens" })}</span><span>{t({ en: "Freshness", sv: "Färskhet" })}</span>
</div>
{sources.map((s) => (
<div key={s.source_id} className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
<span className="font-medium">{s.name}</span>
<span className="font-mono text-xs">{s.market}</span>
<span className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.06em]">
<span className="inline-block h-2 w-2" style={{ backgroundColor: ACCESS_COLOR[s.access] ?? "var(--muted-foreground)" }} />
{s.access}
</span>
<span className="truncate pr-3 text-xs text-muted-foreground">{s.licence}</span>
<span className="font-mono text-xs text-muted-foreground">{s.cadence}</span>
</div>
))}
</div>
)}
</PortalShell>
);
}
-95
View File
@@ -1,95 +0,0 @@
"use client";
import { useState } from "react";
import { PortalShell } from "@/components/portal-shell";
import { useT } from "@/components/locale-provider";
export default function ToolsPage() {
const t = useT();
return (
<PortalShell variant="admin" title={t({ en: "Tools", sv: "Verktyg" })}>
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
{t({ en: "Quick lookups against legal sources. Provenance on every hit.", sv: "Snabboppslag mot lagliga källor. Härkomst på varje träff." })}
</p>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<VehicleTool />
<ScreenTool />
</div>
</PortalShell>
);
}
function VehicleTool() {
const t = useT();
const [plate, setPlate] = useState("");
const [res, setRes] = useState<Record<string, unknown> | null>(null);
const [busy, setBusy] = useState(false);
async function lookup() {
if (!plate.trim()) return;
setBusy(true); setRes(null);
try { setRes(await (await fetch(`/api/vehicles?plate=${encodeURIComponent(plate)}`)).json()); } catch { setRes({ error: true }); }
setBusy(false);
}
const v = (res?.vehicle ?? null) as { make?: string; model?: string; year?: string; reg_plate?: string } | null;
return (
<section className="border border-border p-6">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{t({ en: "Vehicle — RDW (NL)", sv: "Fordon — RDW (NL)" })}</h2>
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-3)" }} />
</div>
<div className="mt-4 flex gap-2">
<input value={plate} onChange={(e) => setPlate(e.target.value)} placeholder={t({ en: "Registration no. (NL)", sv: "Registreringsnr (NL)" })} className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs uppercase outline-none focus:border-foreground" />
<button onClick={lookup} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : t({ en: "Look up", sv: "Slå upp" })}</button>
</div>
<div className="mt-4 min-h-[60px] font-mono text-xs">
{res === null ? <span className="text-muted-foreground">—</span>
: v ? (
<div className="space-y-1">
<div>{v.make} {v.model}</div>
<div className="text-muted-foreground">{v.reg_plate} · {v.year ?? "—"} · {t({ en: "source RDW", sv: "källa RDW" })}</div>
</div>
) : <span className="text-muted-foreground">{t({ en: "No match (not available).", sv: "Ingen träff (ej tillgänglig)." })}</span>}
</div>
</section>
);
}
function ScreenTool() {
const t = useT();
const [q, setQ] = useState("");
const [matches, setMatches] = useState<{ name: string; score: number; schema: string }[] | null>(null);
const [busy, setBusy] = useState(false);
async function screen() {
if (!q.trim()) return;
setBusy(true); setMatches(null);
try { const d = await (await fetch(`/api/screen?q=${encodeURIComponent(q)}`)).json(); setMatches(d.matches ?? []); } catch { setMatches([]); }
setBusy(false);
}
return (
<section className="border border-border p-6">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{t({ en: "Sanctions / PEP — OpenSanctions", sv: "Sanktioner / PEP — OpenSanctions" })}</h2>
<span className="inline-block h-2 w-2" style={{ backgroundColor: "var(--prov-2)" }} />
</div>
<div className="mt-4 flex gap-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t({ en: "Name or company", sv: "Namn eller bolag" })} className="flex-1 border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<button onClick={screen} disabled={busy} className="bg-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">{busy ? "…" : t({ en: "Screen", sv: "Skärma" })}</button>
</div>
<div className="mt-4 min-h-[60px] font-mono text-xs">
{matches === null ? <span className="text-muted-foreground">—</span>
: matches.length === 0 ? <span style={{ color: "var(--prov-3)" }}>{t({ en: "No matches.", sv: "Inga träffar." })}</span>
: (
<ul className="space-y-1">
{matches.map((m, i) => (
<li key={i} className="flex justify-between gap-3">
<span>{m.name}</span>
<span className="text-muted-foreground">{m.schema}{m.score ? ` · ${Math.round(m.score * 100)}%` : ""}</span>
</li>
))}
</ul>
)}
<p className="mt-3 text-[10.5px] text-muted-foreground">{t({ en: "Requires verified OpenSanctions terms/key for full matches.", sv: "Kräver verifierade OpenSanctions-villkor/nyckel för fullständiga träffar." })}</p>
</div>
</section>
);
}
-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);
}
}
-40
View File
@@ -1,40 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
import { ensureTrialCredits, readTrialCredits } from '@/src/document-workbench/trial-credits';
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
import { resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/** GET /api/0pt/credits/balance — the authenticated tenant's credit balance. */
export async function GET(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const url = repositoryDatabaseUrl();
if (!url) {
return NextResponse.json(
{
error: 'CREDITS_NOT_CONFIGURED',
message: 'Credits are not available until the database is configured',
},
{ status: 503 },
);
}
try {
const ledger = new PostgresCreditLedger(getRepositorySql(url));
const trial = readTrialCredits();
if (trial > 0) await ensureTrialCredits(ledger, verified.scope.tenantId, trial);
const balance = await ledger.balance(verified.scope.tenantId);
return NextResponse.json({ tenant_id: verified.scope.tenantId, ...balance });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Credit balance unavailable';
const notConfigured = /RLS-enforced|isolation migration/i.test(message);
return NextResponse.json(
{ error: notConfigured ? 'CREDITS_NOT_CONFIGURED' : 'CREDITS_UNAVAILABLE', message },
{ status: 503 },
);
}
}
-155
View File
@@ -1,155 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { getStripe } from '@/lib/stripe';
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
import { findPack, readCreditPriceBook } from '@/src/document-workbench/price-book';
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function appBaseUrl(request: NextRequest): string {
const configured = process.env.NEXT_PUBLIC_APP_URL;
if (configured && /^https:\/\//.test(configured)) return configured.replace(/\/+$/, '');
return request.nextUrl.origin;
}
/**
* POST /api/0pt/credits/checkout { pack_id }
*
* Creates a Stripe Checkout Session and stores the server-owned price snapshot
* BEFORE any credit exists. Credits are granted only when the Stripe webhook
* re-reads the session and its payment intent and the ledger accepts the
* idempotent verified-payment command.
*/
export async function POST(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const { tenantId } = verified.scope;
if (process.env.OPT_CREDITS_MODE === 'off') {
return NextResponse.json(
{
error: 'CREDITS_DISABLED',
message: 'Billing is not active yet (pilot mode). No payment can be taken.',
},
{ status: 503 },
);
}
const book = readCreditPriceBook();
if (!book) {
return NextResponse.json(
{ error: 'PRICE_BOOK_NOT_CONFIGURED', message: 'Credit purchases are not available yet' },
{ status: 503 },
);
}
const stripe = getStripe();
if (!stripe) {
return NextResponse.json(
{ error: 'STRIPE_NOT_CONFIGURED', message: 'Payments are not available yet' },
{ status: 503 },
);
}
const databaseUrl = repositoryDatabaseUrl();
if (!databaseUrl) {
return NextResponse.json(
{
error: 'CREDITS_NOT_CONFIGURED',
message: 'Credits are not available until the database is configured',
},
{ status: 503 },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
}
const packId = (body as { pack_id?: unknown } | null)?.pack_id;
if (typeof packId !== 'string' || !isValidScopeId(packId)) {
return NextResponse.json(
{ error: 'INVALID_PACK', message: 'A valid pack_id is required' },
{ status: 400 },
);
}
const pack = findPack(book, packId);
if (!pack) {
return NextResponse.json({ error: 'UNKNOWN_PACK', message: 'Unknown credit pack' }, { status: 404 });
}
const purchaseId = crypto.randomUUID();
const base = appBaseUrl(request);
const metadata = {
kind: '0pt_credit_purchase',
tenant_id: tenantId,
purchase_id: purchaseId,
price_book_version: book.version,
};
let session;
try {
session = await stripe.checkout.sessions.create({
mode: 'payment',
client_reference_id: tenantId,
line_items: [
{
quantity: 1,
price_data: {
currency: book.currency.toLowerCase(),
unit_amount: pack.amountMinor,
product_data: { name: `${pack.credits} 0pt credits` },
},
},
],
metadata,
payment_intent_data: { metadata },
success_url: `${base}/portal/krediter?status=success&purchase=${encodeURIComponent(purchaseId)}`,
cancel_url: `${base}/portal/krediter?status=cancelled`,
});
} catch {
return NextResponse.json(
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider could not start a checkout' },
{ status: 502 },
);
}
try {
await new PostgresCreditPurchaseStore(getRepositorySql(databaseUrl)).create({
tenantId,
purchaseId,
checkoutSessionId: session.id,
amountMinor: pack.amountMinor,
currency: book.currency,
credits: pack.credits,
});
} catch (error: unknown) {
// Never leave a payable session with no server-side snapshot behind it.
try {
await stripe.checkout.sessions.expire(session.id);
} catch {
/* best effort; the webhook will refuse the unmatched session */
}
const message = error instanceof Error ? error.message : 'Credit purchase could not be stored';
return NextResponse.json({ error: 'CREDITS_NOT_CONFIGURED', message }, { status: 503 });
}
if (!session.url) {
return NextResponse.json(
{ error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider returned no checkout URL' },
{ status: 502 },
);
}
return NextResponse.json(
{
purchase_id: purchaseId,
checkout_url: session.url,
credits: pack.credits,
amount_minor: pack.amountMinor,
currency: book.currency,
},
{ status: 201 },
);
}
-37
View File
@@ -1,37 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
import { resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/0pt/credits/packs — sellable credit packs from the server-owned
* price book. No price is ever accepted from the client.
*/
export async function GET(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const book = readCreditPriceBook();
if (!book) {
return NextResponse.json(
{
error: 'PRICE_BOOK_NOT_CONFIGURED',
message: 'Credit packs are not available until the server price book is configured',
},
{ status: 503 },
);
}
return NextResponse.json({
version: book.version,
currency: book.currency,
purchases_enabled: process.env.OPT_CREDITS_MODE !== 'off',
packs: book.packs.map((pack) => ({
id: pack.id,
credits: pack.credits,
amount_minor: pack.amountMinor,
})),
});
}
-27
View File
@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
import { datasetService } from "@/src/services/datasets";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ datasetId: string }> },
) {
try {
const { datasetId } = await params;
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const dataset = await datasetService.getDataset(datasetId);
if (dataset.tenant_id !== tenantId) {
throw new SiaxError("NOT_FOUND", "0pt.dataset", `dataset ${datasetId} not found`);
}
return NextResponse.json(dataset);
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
import { datasetVersionService } from "@/src/services/datasets";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ datasetId: string; version: string }> },
) {
try {
const { datasetId, version: rawVersion } = await params;
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const version = Number(rawVersion);
if (!Number.isSafeInteger(version) || version < 1) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "version must be a positive integer");
}
return NextResponse.json(await datasetVersionService.getVersion(datasetId, tenantId, version));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
import { datasetVersionService } from "@/src/services/datasets";
function required(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !value.trim()) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", `${field} is required`);
}
return value.trim();
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ datasetId: string }> },
) {
try {
const { datasetId } = await params;
const raw = await request.json();
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "JSON object body is required");
}
const body = raw as Record<string, unknown>;
const tenantId = required(body, "tenant_id");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
if (!Array.isArray(body.artifact_refs) || !body.artifact_refs.every((value) => typeof value === "string")) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "artifact_refs must be a string array");
}
const recordCount = body.record_count === undefined ? undefined : Number(body.record_count);
const idempotencyKey = request.headers.get("idempotency-key")?.trim()
|| (typeof body.idempotency_key === "string" ? body.idempotency_key.trim() : "")
|| undefined;
const version = await datasetVersionService.createVersion({
dataset_id: datasetId,
tenant_id: tenantId,
project_id: required(body, "project_id"),
artifact_refs: body.artifact_refs,
schema_version: required(body, "schema_version"),
...(recordCount === undefined ? {} : { record_count: recordCount }),
...(typeof body.description === "string" && body.description.trim()
? { description: body.description.trim() }
: {}),
created_by: request.headers.get("x-0pt-verified-sub") ?? "local-dev",
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
? { correlation_id: body.correlation_id.trim() }
: {}),
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
});
return NextResponse.json(version, { status: 201 });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ datasetId: string }> },
) {
try {
const { datasetId } = await params;
const tenantId = new URL(request.url).searchParams.get("tenant_id")?.trim();
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
return NextResponse.json(await datasetVersionService.listVersions(datasetId, tenantId));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-91
View File
@@ -1,91 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
import { datasetService } from "@/src/services/datasets";
import type { DatasetStatus, DatasetType } from "@/src/types/dataset";
const DATASET_TYPES = new Set<DatasetType>(["raw", "structured", "enriched", "curated", "feature", "derived"]);
const DATASET_STATUSES = new Set<DatasetStatus>(["active", "archived", "deprecated"]);
function actorId(request: NextRequest): string {
return request.headers.get("x-0pt-verified-sub") ?? "local-dev";
}
function stringField(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !value.trim()) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", `${field} is required`);
}
return value.trim();
}
export async function POST(request: NextRequest) {
try {
const raw = await request.json();
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "JSON object body is required");
}
const body = raw as Record<string, unknown>;
const tenantId = stringField(body, "tenant_id");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const datasetType = stringField(body, "dataset_type") as DatasetType;
if (!DATASET_TYPES.has(datasetType)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported dataset_type");
}
const status = body.status === undefined ? undefined : String(body.status) as DatasetStatus;
if (status !== undefined && !DATASET_STATUSES.has(status)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported status");
}
const dataset = await datasetService.registerDataset({
dataset_id: stringField(body, "dataset_id"),
tenant_id: tenantId,
project_id: stringField(body, "project_id"),
name: stringField(body, "name"),
dataset_type: datasetType,
...(status ? { status } : {}),
...(typeof body.description === "string" && body.description.trim()
? { description: body.description.trim() }
: {}),
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
? { correlation_id: body.correlation_id.trim() }
: {}),
created_by: actorId(request),
});
return NextResponse.json(dataset, { 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")?.trim();
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "tenant_id is required");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const typeRaw = searchParams.get("dataset_type") ?? undefined;
const statusRaw = searchParams.get("status") ?? undefined;
if (typeRaw && !DATASET_TYPES.has(typeRaw as DatasetType)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported dataset_type");
}
if (statusRaw && !DATASET_STATUSES.has(statusRaw as DatasetStatus)) {
throw new SiaxError("INVALID_REQUEST", "0pt.dataset", "unsupported status");
}
return NextResponse.json(await datasetService.listDatasets({
tenant_id: tenantId,
project_id: searchParams.get("project_id") ?? undefined,
dataset_type: typeRaw as DatasetType | undefined,
status: statusRaw as DatasetStatus | undefined,
}));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, documentExtractionSchema } from "@/src/validation/0pt-schemas";
import { documentExtractionService } from "@/src/services/document-extraction-service";
import { assertTenantMatchesVerified, resolveDocumentActor } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
try {
const { documentId } = await params;
const __raw = await request.json();
const __parsed = parseBody(documentExtractionSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
if (tenantGuard) return tenantGuard;
const actor = resolveDocumentActor(request, body.actor_id);
if (actor.response) return actor.response;
if (!actor.actorId || !body.model_id) {
return NextResponse.json({ error: "actor and model_id are required" }, { status: 400 });
}
const result = await documentExtractionService.extractDocument(documentId, {
tenant_id: body.tenant_id,
project_id: body.project_id,
actor_id: actor.actorId,
correlation_id: body.correlation_id ?? crypto.randomUUID(),
model_id: body.model_id,
});
return NextResponse.json(result, { status: 201 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
if (/not configured/i.test(message)) {
return NextResponse.json({ error: message }, { status: 501 });
}
if (/^Policy denied/i.test(message)) {
return NextResponse.json({ error: message }, { status: 403 });
}
if (/^Policy requires approval/i.test(message)) {
return NextResponse.json({ error: message }, { status: 409 });
}
const status = /not found/i.test(message) ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ documentId: string }> }) {
try {
const { documentId } = await params;
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const extractions = await documentExtractionService.listExtractionsForDocument(documentId, tenantId);
return NextResponse.json(extractions);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 404 });
}
}
-64
View File
@@ -1,64 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, documentCaptureSchema } from "@/src/validation/0pt-schemas";
import { documentCaptureService } from "@/src/services/document-capture-service";
import { assertTenantMatchesVerified, resolveDocumentActor } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
const __raw = await request.json();
const __parsed = parseBody(documentCaptureSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
if (tenantGuard) return tenantGuard;
const actor = resolveDocumentActor(request, body.actor_id);
if (actor.response) return actor.response;
const result = await documentCaptureService.captureFromMailbox(
{
mailbox: {
tenantId: body.mailbox?.tenant_id,
clientId: body.mailbox?.client_id,
clientSecret: body.mailbox?.client_secret,
mailboxAddress: body.mailbox?.mailbox_address,
folder: body.mailbox?.folder,
},
since_iso: body.since_iso,
top: body.top,
max_pages: body.max_pages,
},
{
tenant_id: body.tenant_id,
project_id: body.project_id,
// In enforced mode middleware supplies the verified subject; the
// body can only agree with it. Development retains the old fallback.
actor_id: actor.actorId ?? "",
correlation_id: body.correlation_id ?? "",
},
);
return NextResponse.json(result, { status: 201 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 400 });
}
}
// P014: `tenant_id` used to be optional here, and
// DocumentCaptureService.listDocuments treats an absent tenant_id filter as
// "no restriction" — so calling this with no query params at all returned
// EVERY tenant's captured documents to any authenticated caller. Every
// sibling list/get route in this file already requires tenant_id; this was
// the one outlier. Now required, same as the rest of the surface.
export async function GET(request: NextRequest) {
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const projectId = searchParams.get("project_id") ?? undefined;
const documents = await documentCaptureService.listDocuments({ tenant_id: tenantId, project_id: projectId });
return NextResponse.json(documents);
}
-56
View File
@@ -1,56 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { readCreditPriceBook } from '@/src/document-workbench/price-book';
import { resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function csv(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
/**
* GET /api/0pt/documents/models — the server-approved model routes and limits
* the customer may choose from. Never a free-form model list.
*/
export async function GET(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const models = csv(process.env.OPT_ALLOWED_MODELS);
const dataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
const destination = process.env.OPT_MODEL_DESTINATION;
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
if (
models.length === 0 ||
dataClasses.length === 0 ||
(destination !== 'internal' && destination !== 'external') ||
!Number.isSafeInteger(maxInputTokens) ||
maxInputTokens <= 0 ||
!Number.isSafeInteger(maxOutputTokens) ||
maxOutputTokens <= 0
) {
return NextResponse.json(
{
error: 'MODEL_POLICY_NOT_CONFIGURED',
message: 'Document structuring is not available until the model policy is configured',
},
{ status: 503 },
);
}
const book = readCreditPriceBook();
return NextResponse.json({
models,
data_classes: dataClasses,
destination,
external_transfer_allowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
max_input_tokens: maxInputTokens,
max_output_tokens: maxOutputTokens,
price_book_version: book?.version ?? null,
});
}
-454
View File
@@ -1,454 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-workbench/doc0-source-client';
import { Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client';
import { createInf0Gateway } from '@/src/document-workbench/inf0-gateway';
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
import { ensureTrialCredits, readTrialCredits } from '@/src/document-workbench/trial-credits';
import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book';
import { structureDocument } from '@/src/document-workbench/structure-document';
import { exportValidatedOutput } from '@/src/document-workbench/export-validated-output';
import type {
OutputFieldContract,
OutputFieldType,
} from '@/src/document-workbench/structured-output-validation';
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
import { createProjectAuthorizer } from '@/src/document-workbench/project-access';
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const FIELD_TYPES: readonly OutputFieldType[] = ['string', 'integer', 'boolean', 'decimal', 'date'];
const DATA_CLASSES = ['none', 'low', 'medium', 'high', 'restricted'] as const;
function csv(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
function modelPolicy() {
const allowedModelIds = csv(process.env.OPT_ALLOWED_MODELS);
const allowedDataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES);
const destination = process.env.OPT_MODEL_DESTINATION;
const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0');
const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0');
if (
allowedModelIds.length === 0 ||
allowedDataClasses.length === 0 ||
(destination !== 'internal' && destination !== 'external') ||
!Number.isSafeInteger(maxInputTokens) ||
maxInputTokens <= 0 ||
!Number.isSafeInteger(maxOutputTokens) ||
maxOutputTokens <= 0
) {
return undefined;
}
return {
allowedModelIds,
allowedDataClasses,
destination: destination as 'internal' | 'external',
externalTransferAllowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true',
maxInputTokens,
maxOutputTokens,
};
}
function parseFields(raw: unknown): OutputFieldContract[] | undefined {
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) return undefined;
const fields: OutputFieldContract[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
const field = item as Record<string, unknown>;
if (
typeof field.name !== 'string' ||
!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(field.name) ||
seen.has(field.name) ||
typeof field.type !== 'string' ||
!FIELD_TYPES.includes(field.type as OutputFieldType)
) {
return undefined;
}
seen.add(field.name);
fields.push({ name: field.name, type: field.type as OutputFieldType, required: true });
}
return fields;
}
interface StructureBody {
project_id?: unknown;
document_id?: unknown;
version_id?: unknown;
checksum_sha256?: unknown;
pipeline_stage?: unknown;
instruction?: unknown;
fields?: unknown;
model_id?: unknown;
data_class?: unknown;
format?: unknown;
}
/**
* POST /api/0pt/documents/structure
*
* Upload → DOC0 segments → INF0 model → mechanically validated, evidence-bound
* output (optionally exported). Credits are reserved before the model runs and
* settled to the measured usage afterwards.
*
* Nothing is trusted from the client except the requested target fields and
* instruction. Source text is re-read from DOC0 and every returned value must
* point at an exact span in that text.
*/
export async function POST(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const { tenantId, actorId } = verified.scope;
const runtimeConfig = readDocumentWorkbenchRuntime();
const gateway = createInf0Gateway();
if (!runtimeConfig.doc0 || !gateway) {
return NextResponse.json(
{
error: 'WORKBENCH_NOT_CONFIGURED',
message: 'Document structuring is not available until DOC0 and INF0 are configured',
},
{ status: 503 },
);
}
const policy = modelPolicy();
if (!policy) {
return NextResponse.json(
{
error: 'MODEL_POLICY_NOT_CONFIGURED',
message: 'Document structuring is not available until the model policy is configured',
},
{ status: 503 },
);
}
// Explicit pilot switch: `OPT_CREDITS_MODE=off` runs structuring without a
// wallet. It is deliberately NOT a silent fallback — the response reports
// `credits: null` and the operator has named the mode.
const creditsEnabled =
process.env.OPT_CREDITS_MODE !== 'off' &&
Boolean(readCreditPriceBook()) &&
Boolean(repositoryDatabaseUrl());
const priceBook = creditsEnabled ? readCreditPriceBook() : undefined;
const databaseUrl = creditsEnabled ? repositoryDatabaseUrl() : undefined;
if (!creditsEnabled && process.env.OPT_CREDITS_MODE !== 'off') {
return NextResponse.json(
{
error: 'CREDITS_NOT_CONFIGURED',
message: 'Document structuring is not available until credits are configured',
},
{ status: 503 },
);
}
let body: StructureBody;
try {
body = (await request.json()) as StructureBody;
} catch {
return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 });
}
const projectId = body.project_id;
const documentId = body.document_id;
const versionId = body.version_id;
const checksum = body.checksum_sha256;
const pipelineStage = body.pipeline_stage;
const instruction = body.instruction;
const modelId = body.model_id;
const dataClass = body.data_class ?? 'low';
const format = body.format ?? 'json';
if (pipelineStage !== 'parsed') {
return NextResponse.json(
{
error: 'DOCUMENT_NOT_PARSED',
message:
'The document has no verified text layer yet (parse_failed or not parsed). OCR is required before structuring.',
},
{ status: 422 },
);
}
if (
!isValidScopeId(projectId) ||
!isValidScopeId(documentId) ||
!isValidScopeId(versionId) ||
typeof checksum !== 'string' ||
!/^[0-9a-f]{64}$/.test(checksum) ||
typeof instruction !== 'string' ||
!instruction.trim() ||
instruction.length > 4000 ||
typeof modelId !== 'string' ||
!policy.allowedModelIds.includes(modelId) ||
typeof dataClass !== 'string' ||
!(DATA_CLASSES as readonly string[]).includes(dataClass) ||
(format !== 'json' && format !== 'csv')
) {
return NextResponse.json(
{ error: 'INVALID_REQUEST', message: 'Invalid document structure request' },
{ status: 400 },
);
}
const fields = parseFields(body.fields);
if (!fields) {
return NextResponse.json(
{ error: 'INVALID_FIELDS', message: 'A valid field contract is required' },
{ status: 400 },
);
}
const authorize = createProjectAuthorizer();
if (!(await authorize({ tenantId, projectId, actorId }))) {
return NextResponse.json(
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
{ status: 403 },
);
}
const correlationId = request.headers.get('x-request-id') ?? crypto.randomUUID();
const context = {
tenantId,
projectId,
actorId,
capability: '0pt.document.structure.v1',
correlationId,
idempotencyKey: correlationId,
};
let sources;
try {
sources = await new Doc0SourceClient({
baseUrl: runtimeConfig.doc0.baseUrl,
serviceToken: runtimeConfig.doc0.serviceToken,
}).read({
tenantId,
projectId,
actorId: runtimeConfig.doc0.actorId ?? actorId,
requestId: correlationId,
receipt: {
documentId,
versionId,
checksumSha256: checksum,
pipelineStage: 'parsed',
},
});
} catch (error: unknown) {
if (error instanceof Doc0SourceUnavailableError) {
return NextResponse.json(
{
error: 'DOC0_SOURCE_UNAVAILABLE',
message:
'The extracted source could not be verified (version, checksum, page cap or tenant). Review or reconciliation is required.',
},
{ status: 422 },
);
}
return NextResponse.json(
{ error: 'DOC0_SOURCE_UNAVAILABLE', message: 'The extracted source is unavailable' },
{ status: 502 },
);
}
console.log('[0pt-workbench] doc0 source read:', sources.sources.length, 'segments');
const sourceChars = sources.sources.reduce((total, source) => total + source.text.length, 0);
const estimatedInputTokens = Math.ceil(JSON.stringify(sources.sources).length / 4) + 1024;
if (estimatedInputTokens > policy.maxInputTokens) {
return NextResponse.json(
{
error: 'SOURCE_TOO_LARGE',
message: 'The document is larger than the configured model input limit',
},
{ status: 413 },
);
}
const maximumCredits =
creditsEnabled && priceBook
? creditsForUsage(priceBook, {
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
inputTokens: estimatedInputTokens,
outputTokens: policy.maxOutputTokens,
})
: 0;
const reservationId = crypto.randomUUID();
const ledger =
creditsEnabled && databaseUrl ? new PostgresCreditLedger(getRepositorySql(databaseUrl)) : undefined;
if (ledger) {
try {
await ensureTrialCredits(ledger, tenantId, readTrialCredits());
await ledger.apply({
tenantId,
idempotencyKey: `reserve:${reservationId}`,
kind: 'reserve',
reference: reservationId,
amount: maximumCredits,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Credit reservation failed';
if (/Insufficient available credits/i.test(message)) {
return NextResponse.json(
{
error: 'INSUFFICIENT_CREDITS',
message: 'Not enough credits for this job',
required_credits: maximumCredits,
},
{ status: 402 },
);
}
return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 });
}
}
try {
const result = await structureDocument({
gateway,
request: {
context,
admission: {
tenantId,
projectId,
jobId: `doc:${documentId}:${correlationId}`,
modelId,
dataClass,
maxInputTokens: policy.maxInputTokens,
maxOutputTokens: policy.maxOutputTokens,
maximumCredits,
},
policy: {
tenantId,
projectId,
allowedModelIds: policy.allowedModelIds,
allowedDataClasses: policy.allowedDataClasses,
destination: policy.destination,
externalTransferAllowed: policy.externalTransferAllowed,
maxInputTokens: policy.maxInputTokens,
maxOutputTokens: policy.maxOutputTokens,
},
reservation: ledger
? {
tenantId,
projectId,
jobId: `doc:${documentId}:${correlationId}`,
status: 'reserved',
credits: maximumCredits,
}
: undefined,
},
countTokens: async (messages) => Math.ceil(JSON.stringify(messages).length / 4),
instructions: instruction,
fields,
sources: sources.sources,
});
const charged =
ledger && priceBook
? Math.min(
maximumCredits,
creditsForUsage(priceBook, {
pages: Math.max(1, Math.ceil(sourceChars / 1800)),
inputTokens: result.model.promptTokens,
outputTokens: result.model.completionTokens,
}),
)
: 0;
let balance;
if (ledger) {
try {
balance = (
await ledger.apply({
tenantId,
idempotencyKey: `settle:${reservationId}`,
kind: 'settle',
reference: reservationId,
amount: charged,
})
).balance;
} catch {
// The model result is real; keep the reservation for reconciliation
// rather than reporting a balance we could not write.
balance = undefined;
}
}
if (result.status !== 'mechanically_validated') {
return NextResponse.json(
{
status: 'needs_review',
issues: result.issues,
model: result.model,
credits: ledger ? { reserved: maximumCredits, charged, balance } : null,
},
{ status: 200 },
);
}
const exported =
format === 'json'
? exportValidatedOutput({
format: 'json',
fields,
sources: sources.sources,
value: result.value,
evidence: result.evidence,
})
: exportValidatedOutput({
format: 'csv',
fields,
sources: sources.sources,
value: result.value,
evidence: result.evidence,
});
return NextResponse.json(
{
status: 'mechanically_validated',
value: result.value,
evidence: result.evidence,
model: result.model,
credits: ledger ? { reserved: maximumCredits, charged, balance } : null,
export:
exported.status === 'ready'
? { media_type: exported.mediaType, content: exported.content }
: undefined,
},
{ status: 200 },
);
} catch (error: unknown) {
if (error instanceof Inf0OutcomeUnknownError) {
// Never release a reservation whose model call may have completed.
return NextResponse.json(
{
status: 'outcome_unknown',
message:
'The model call may have completed. The credit reservation is kept until its usage is reconciled.',
reservation_id: reservationId,
},
{ status: 202 },
);
}
if (ledger) {
try {
await ledger.apply({
tenantId,
idempotencyKey: `release:${reservationId}`,
kind: 'release',
reference: reservationId,
amount: 0,
});
} catch {
/* the reservation stays for reconciliation */
}
}
const message = error instanceof Error ? error.message : 'Structuring failed';
console.error('[0pt-workbench] structure failed:', message);
return NextResponse.json(
{ error: 'STRUCTURE_FAILED', message, reservation_id: reservationId },
{ status: 502 },
);
}
}
-241
View File
@@ -1,241 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { Doc0UploadClient } from '@/src/document-workbench/doc0-upload-client';
import {
DocumentUploadCoordinator,
type CoordinatedUpload,
} from '@/src/document-workbench/upload-coordinator';
import { PostgresUploadAttemptStore } from '@/src/document-workbench/upload-attempt-store';
import {
createProjectAuthorizer,
projectAuthorizationConfigured,
} from '@/src/document-workbench/project-access';
import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config';
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const CLASSIFICATIONS = [
'public',
'internal',
'confidential',
'restricted',
'personal-data',
'financial',
'legal',
] as const;
function isClassification(value: string): value is (typeof CLASSIFICATIONS)[number] {
return (CLASSIFICATIONS as readonly string[]).includes(value);
}
function idempotencyKey(request: NextRequest, form: FormData): string | undefined {
const fromHeader = request.headers.get('idempotency-key');
const fromBody = form.get('idempotency_key');
const value = fromHeader ?? (typeof fromBody === 'string' ? fromBody : undefined);
return value && /^[\x21-\x7e]{1,256}$/.test(value) ? value : undefined;
}
/**
* POST /api/0pt/documents/upload — customer document upload.
*
* multipart/form-data:
* file (required) the original bytes; never trusted metadata
* project_id (required) verified project the file belongs to
* idempotency_key (required) or `Idempotency-Key` header
* classification optional, defaults to `restricted` (conservative)
*
* The tenant and actor come ONLY from middleware's verified headers. The route
* fails closed when project authorization or DOC0 is not configured.
*/
export async function POST(request: NextRequest) {
const verified = resolveVerifiedScope(request);
if ('response' in verified) return verified.response;
const { tenantId, actorId } = verified.scope;
const runtimeConfig = readDocumentWorkbenchRuntime();
if (!runtimeConfig.doc0) {
return NextResponse.json(
{
error: 'DOC0_NOT_CONFIGURED',
message: 'Document upload is not available until DOC0 is configured',
},
{ status: 503 },
);
}
if (!projectAuthorizationConfigured()) {
return NextResponse.json(
{
error: 'PROJECT_AUTHORIZATION_NOT_CONFIGURED',
message: 'Document upload is not available until project authorization is configured',
},
{ status: 503 },
);
}
const databaseUrl = repositoryDatabaseUrl();
if (!databaseUrl) {
return NextResponse.json(
{
error: 'UPLOAD_JOURNAL_NOT_CONFIGURED',
message: 'Document upload is not available until its durable journal is configured',
},
{ status: 503 },
);
}
const declaredLength = Number(request.headers.get('content-length') ?? '0');
if (Number.isFinite(declaredLength) && declaredLength > runtimeConfig.doc0.maxUploadBytes + 128 * 1024) {
return NextResponse.json(
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
{ status: 413 },
);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return NextResponse.json(
{ error: 'INVALID_UPLOAD', message: 'A multipart form with a file is required' },
{ status: 400 },
);
}
const file = form.get('file');
const projectId = form.get('project_id');
const key = idempotencyKey(request, form);
const classificationRaw = form.get('classification');
const classification =
typeof classificationRaw === 'string' && classificationRaw.trim()
? classificationRaw.trim()
: 'restricted';
if (!(file instanceof File) || file.size <= 0) {
return NextResponse.json(
{ error: 'INVALID_UPLOAD', message: 'A non-empty file is required' },
{ status: 400 },
);
}
if (!isValidScopeId(projectId)) {
return NextResponse.json(
{ error: 'INVALID_PROJECT', message: 'A valid project_id is required' },
{ status: 400 },
);
}
if (!key) {
return NextResponse.json(
{
error: 'IDEMPOTENCY_KEY_REQUIRED',
message: 'An idempotency key is required for document uploads',
},
{ status: 400 },
);
}
if (!isClassification(classification)) {
return NextResponse.json(
{ error: 'INVALID_CLASSIFICATION', message: 'Unknown classification' },
{ status: 400 },
);
}
if (file.size > runtimeConfig.doc0.maxUploadBytes) {
return NextResponse.json(
{ error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' },
{ status: 413 },
);
}
let bytes: Uint8Array;
try {
bytes = new Uint8Array(await file.arrayBuffer());
} catch {
return NextResponse.json(
{ error: 'INVALID_UPLOAD', message: 'The uploaded file could not be read' },
{ status: 400 },
);
}
if (bytes.byteLength === 0 || bytes.byteLength !== file.size) {
return NextResponse.json(
{ error: 'INVALID_UPLOAD', message: 'The uploaded file is incomplete' },
{ status: 400 },
);
}
const authorize = createProjectAuthorizer();
const sql = getRepositorySql(databaseUrl);
// DOC0 authorizes the SERVICE identity and requires x-actor-id to be an
// actor that identity holds a grant for. The end-user actor is still the
// journal/authorization scope on our side; only the DOC0 call acts as the
// service.
const doc0ActorId = runtimeConfig.doc0.actorId ?? actorId;
const doc0Client = new Doc0UploadClient({
baseUrl: runtimeConfig.doc0.baseUrl,
serviceToken: runtimeConfig.doc0.serviceToken,
maxBytes: runtimeConfig.doc0.maxUploadBytes,
});
const coordinator = new DocumentUploadCoordinator({
attempts: new PostgresUploadAttemptStore(sql),
doc0: {
upload: (input) => doc0Client.upload({ ...input, actorId: doc0ActorId }),
inspect: (input) => doc0Client.inspect({ ...input, actorId: doc0ActorId }),
},
authorize: async (scope) => authorize(scope),
maxBytes: runtimeConfig.doc0.maxUploadBytes,
});
let outcome: CoordinatedUpload;
try {
outcome = await coordinator.execute({
tenantId,
projectId,
actorId,
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
idempotencyKey: key,
filename: file.name || 'source',
mimeType: file.type || 'application/octet-stream',
classification,
bytes,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Upload failed';
if (/not authorized/i.test(message)) {
return NextResponse.json(
{ error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' },
{ status: 403 },
);
}
if (/cannot be reused/i.test(message)) {
return NextResponse.json(
{
error: 'IDEMPOTENCY_CONFLICT',
message: 'The idempotency key was already used for different content',
},
{ status: 409 },
);
}
return NextResponse.json({ error: 'UPLOAD_FAILED', message }, { status: 400 });
}
if (outcome.status === 'receipt_verified') {
return NextResponse.json(
{
status: 'receipt_verified',
document_id: outcome.receipt.documentId,
version_id: outcome.receipt.versionId,
checksum_sha256: outcome.receipt.checksumSha256,
pipeline_stage: outcome.receipt.pipelineStage,
},
{ status: 201 },
);
}
return NextResponse.json(
{
status: 'outcome_unknown',
message:
'The upload may have been accepted. Its journal record is reported below; it does not prove storage or extraction.',
observation: outcome.observation ?? { status: 'reconciliation_required' },
},
{ status: 202 },
);
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
import { getBatchEntityResolutionService } from "@/src/services/entity-resolution";
function required(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !value.trim()) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `${field} is required`);
}
return value.trim();
}
export async function POST(request: NextRequest) {
try {
const raw = await request.json();
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "JSON object body is required");
}
const body = raw as Record<string, unknown>;
const tenantId = required(body, "tenant_id");
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const idempotencyKey = request.headers.get("idempotency-key")?.trim()
|| (typeof body.idempotency_key === "string" ? body.idempotency_key.trim() : "")
|| undefined;
const job = await getBatchEntityResolutionService().submitBatchResolution({
tenant_id: tenantId,
project_id: required(body, "project_id"),
records_artifact_ref: required(body, "records_artifact_ref"),
actor_id: request.headers.get("x-0pt-verified-sub") ?? "local-dev",
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
? { correlation_id: body.correlation_id.trim() }
: {}),
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
});
return NextResponse.json(job, { status: 202 });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-17
View File
@@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { parseErCandidateRequest } from "@/src/er/request-validation";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { entityResolutionService } from "@/src/services/entity-resolution";
export async function POST(request: NextRequest) {
try {
const input = parseErCandidateRequest(await request.json());
const guard = assertTenantMatchesVerified(request, input.tenant_id);
if (guard) return guard;
return NextResponse.json(entityResolutionService.generateCandidates(input));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-101
View File
@@ -1,101 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyInternalServiceToken } from "@/src/auth/internal-token";
import {
EntityResolutionService,
type ErCandidateResult,
type ErRecord,
} from "@/src/er/entity-resolution-service";
import { parseErCandidateRequest } from "@/src/er/request-validation";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
const er = new EntityResolutionService();
function rawStringField(record: ErRecord, field: string): string | undefined {
const value = record.fields[field];
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized || undefined;
}
function stableIdentifier(record: ErRecord, field: string): string | undefined {
const value = rawStringField(record, field);
if (!value) return undefined;
const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
return normalized || undefined;
}
function countryIdentifier(record: ErRecord): string | undefined {
const value = rawStringField(record, "registration_country") ?? rawStringField(record, "country");
if (!value) return undefined;
const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
return normalized || undefined;
}
/**
* Company identity hard rule v3.
* - LEI is globally scoped.
* - National registration numbers are comparable only within the same country.
* - Any comparable stable-ID conflict dominates every positive match.
*/
er.registerHardRule({
rule_id: "0pt.company.stable-identifiers.v3",
decide(a, b) {
let sharedMatch = false;
const aLei = stableIdentifier(a, "lei");
const bLei = stableIdentifier(b, "lei");
if (aLei && bLei) {
if (aLei !== bLei) return "force_non_match";
sharedMatch = true;
}
const aCountry = countryIdentifier(a);
const bCountry = countryIdentifier(b);
const aOrg = stableIdentifier(a, "org_nr");
const bOrg = stableIdentifier(b, "org_nr");
if (aOrg && bOrg && aCountry && bCountry && aCountry === bCountry) {
if (aOrg !== bOrg) return "force_non_match";
sharedMatch = true;
}
return sharedMatch ? "force_match" : "abstain";
},
});
function assertInternal(request: NextRequest): NextResponse | undefined {
if (verifyInternalServiceToken(request.headers)) return undefined;
return NextResponse.json(
{ error: "UNAUTHENTICATED", message: "valid X-Internal-Token is required" },
{ status: 401 },
);
}
/**
* Company/Work boundary: fuzzy similarity is candidate evidence, never identity truth.
* Only product hard rules backed by stable identifiers may emit `match` here.
*/
export function enforceCompanyReviewBoundary(result: ErCandidateResult): ErCandidateResult {
const pairs = result.pairs.map((pair) =>
pair.decided_by === "score" && pair.status === "match"
? { ...pair, status: "pending_review" as const }
: pair,
);
return {
...result,
pairs,
review_queue: pairs.filter((pair) => pair.status === "pending_review"),
};
}
export async function POST(request: NextRequest) {
const denied = assertInternal(request);
if (denied) return denied;
try {
const result = await er.generateCandidates(parseErCandidateRequest(await request.json()));
return NextResponse.json(enforceCompanyReviewBoundary(result));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyInternalServiceToken } from "@/src/auth/internal-token";
import { createAud0Client } from "@/src/clients/aud0-client";
import {
Aud0OutboxDispatcher,
createAud0OutboxRepositoryFromEnv,
} from "@/src/evidence/aud0-outbox";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
function assertInternal(request: NextRequest): NextResponse | undefined {
if (verifyInternalServiceToken(request.headers)) return undefined;
return NextResponse.json(
{ error: "UNAUTHENTICATED", message: "valid X-Internal-Token is required" },
{ status: 401 },
);
}
function repository() {
const repo = createAud0OutboxRepositoryFromEnv();
if (!repo) {
throw new SiaxError(
"DEPENDENCY_UNAVAILABLE",
"0pt.aud0_outbox",
"DATABASE_URL is required for durable evidence dispatch",
);
}
return repo;
}
export async function POST(request: NextRequest) {
const denied = assertInternal(request);
if (denied) return denied;
try {
const raw = await request.json().catch(() => ({})) as { limit?: unknown };
const limit = raw.limit === undefined ? 100 : Number(raw.limit);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
return NextResponse.json(
{ error: "INVALID_REQUEST", message: "limit must be an integer between 1 and 500" },
{ status: 400 },
);
}
const repo = repository();
// CRITICAL: the dispatcher must bypass the durable wrapper. Using the
// default client here would enqueue the event again and could create an
// infinite outbox loop instead of delivering to AUD0.
const rawAud0 = createAud0Client(undefined, { durable: false });
const dispatcher = new Aud0OutboxDispatcher(repo, rawAud0);
const result = await dispatcher.sweep(limit);
const stats = await repo.stats();
return NextResponse.json({ result, stats });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
export async function GET(request: NextRequest) {
const denied = assertInternal(request);
if (denied) return denied;
try {
return NextResponse.json(await repository().stats());
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { mappingService } from "@/src/services/lakehouse-mapping-registry";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
// P014: this lookup used to accept only `mappingId`, with NO tenant check
// anywhere in the request path -- any authenticated caller could read
// another tenant's dataset<->Iceberg-table mapping (namespace, table name,
// storage URI) by guessing or enumerating IDs. Now requires `?tenant_id=`
// and both checks it against the verified token and threads it into the
// service so a foreign mapping_id 404s instead of leaking -- see
// DatasetIcebergMappingService.getMapping's doc comment.
export async function GET(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
try {
const { mappingId } = await params;
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const mapping = await mappingService.getMapping(mappingId, tenantId);
return NextResponse.json(mapping);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 404 });
}
}
@@ -1,45 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, requestIcebergWriteSchema } from "@/src/validation/0pt-schemas";
import { writeService } from "@/src/services/lakehouse-mapping-registry";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
// Registers a new Iceberg snapshot (a write) through ST0RE's table registry
// for an already-mapped dataset. See docs/ICEBERG_WRITE_BOUNDARY.md.
//
// `writeService` is the SHARED instance from
// src/services/lakehouse-mapping-registry.ts -- this route used to construct
// its own `new IcebergWriteService()`, which held a private in-memory mapping
// registry, so a mapping created via POST /lakehouse/mappings was never
// visible here and every write failed with "mapping not found".
//
// The body is validated against requestIcebergWriteSchema before it reaches
// the service (Wave 2 hardening).
export async function POST(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
try {
const { mappingId } = await params;
const __raw = await request.json();
const __parsed = parseBody(requestIcebergWriteSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
if (tenantGuard) return tenantGuard;
const result = await writeService.requestIcebergWrite({
dataset_iceberg_mapping_id: mappingId,
tenant_id: body.tenant_id,
actor_id: body.actor_id,
correlation_id: body.correlation_id,
snapshot_type: body.snapshot_type,
schema_version: body.schema_version,
manifest_uri: body.manifest_uri,
record_count: body.record_count,
size_bytes: body.size_bytes,
heavy: body.heavy,
});
return NextResponse.json(result, { status: 202 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 400 });
}
}
-84
View File
@@ -1,84 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { parseBody, mapDatasetToIcebergTableSchema } from "@/src/validation/0pt-schemas";
import { mappingService, writeService } from "@/src/services/lakehouse-mapping-registry";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import type { IcebergTableFormat } from "@/src/clients/st0re-lakehouse-client";
// Shared instances come from src/services/lakehouse-mapping-registry.ts so a
// mapping created via POST is visible to a later GET, AND to the sibling
// /{mappingId}/writes route, within the same server process. (Each route file
// previously constructed its own DatasetIcebergMappingService/
// IcebergWriteService, so a mapping created here was invisible to the writes
// route -- every write failed with "mapping not found".) This still matches the
// in-memory-per-module convention app/api/0pt/datasets and app/api/0pt/quality
// use; Postgres persistence exists in sql/ but is not wired into route handlers
// in this pass -- same limitation those routes already have.
// POST registers a NEW Iceberg table in ST0RE's table registry (via
// IcebergWriteService.mapDatasetToIcebergTable) and records the
// dataset_iceberg_mapping row that points at it -- this is 0pt's write-path
// entrypoint, see docs/ICEBERG_WRITE_BOUNDARY.md.
//
// The body is validated against mapDatasetToIcebergTableSchema BEFORE it
// reaches the service (Wave 2 hardening): an unvalidated request body used to
// be passed straight into the capability call.
export async function POST(request: NextRequest) {
try {
const __raw = await request.json();
const __parsed = parseBody(mapDatasetToIcebergTableSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
const tenantGuard = assertTenantMatchesVerified(request, body.tenant_id);
if (tenantGuard) return tenantGuard;
const result = await writeService.mapDatasetToIcebergTable({
tenant_id: body.tenant_id,
project_id: body.project_id,
dataset_id: body.dataset_id,
dataset_iceberg_mapping_id: body.dataset_iceberg_mapping_id,
namespace: body.namespace,
table_name: body.table_name,
storage_uri: body.storage_uri,
catalog_ref: body.catalog_ref,
// mapDatasetToIcebergTableSchema types this as a plain string
// (pre-existing, listed among package.json's "9 pre-existing errors
// ... Wave 2 Zod schemas type fields as plain string while the
// services want narrower unions" -- deliberately left to its own
// change, not widened here). This cast changes no runtime behavior:
// an unrecognized value flows through exactly as it did before
// (ST0RE's own table-registration route is the actual validator) --
// it only satisfies the type checker, which previously never saw this
// file (nothing under tests/ imported it before P014's cross-tenant
// regression tests).
table_format: body.table_format as IcebergTableFormat | undefined,
actor_id: body.actor_id,
correlation_id: body.correlation_id,
});
return NextResponse.json(result, { status: 201 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 400 });
}
}
// P014: tenant_id used to be optional here, and
// DatasetIcebergMappingService.listMappings treats an absent tenant_id
// filter as "no restriction" -- omitting the query param returned every
// tenant's dataset<->Iceberg-table mappings (namespace, table name, storage
// URI). Now required and cross-checked, matching the rest of the surface.
export async function GET(request: NextRequest) {
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const mappings = await mappingService.listMappings({
tenant_id: tenantId,
project_id: searchParams.get("project_id") ?? undefined,
dataset_id: searchParams.get("dataset_id") ?? undefined,
});
return NextResponse.json(mappings);
}
-47
View File
@@ -1,47 +0,0 @@
/**
* app/api/0pt/lineage/route.ts
* =============================
* P067 DoD: "source -> output lineage is QUERYABLE". This is the query.
*
* GET /api/0pt/lineage?tenant_id=…&output_name=… -> what fed this output
* GET /api/0pt/lineage?tenant_id=…&input_name=… -> what this input produced
*
* The answer comes from 0pt's local projection of the OpenLineage events it
* emits to AUD0 (src/lineage/lineage-store.ts). AUD0 remains the system of
* record; this endpoint exists because "emitted to AUD0" was never the same
* thing as "answerable from 0pt", and before this route nothing in this repo
* could answer either question.
*/
import { NextRequest, NextResponse } from "next/server";
import { lineageStore } from "@/src/services/source-registry";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tenantId = searchParams.get("tenant_id");
const outputName = searchParams.get("output_name") ?? undefined;
const inputName = searchParams.get("input_name") ?? undefined;
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
if (!outputName && !inputName) {
return NextResponse.json(
{ error: "one of output_name or input_name is required — an unfiltered lineage dump is not a query" },
{ status: 400 },
);
}
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const limitRaw = searchParams.get("limit");
const limit = limitRaw ? Math.min(Math.max(Number(limitRaw), 1), 500) : undefined;
const edges = await lineageStore.query({
tenant_id: tenantId,
output_name: outputName,
input_name: inputName,
limit,
});
return NextResponse.json({ edges, count: edges.length });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-70
View File
@@ -1,70 +0,0 @@
/**
* app/api/0pt/quality/bridge-run/route.ts
* ==========================================
* The P010 TS/Python split-brain bridge (BEFORE_STATE.md §2). This is the
* ONE route the production Python engine
* (services/api/workers/quality_bridge.py, called from
* services/api/workers/pipeline.py) talks to. It:
*
* 1. auto-provisions a deterministic, tenant-scoped baseline rule set for
* (tenant_id, dataset_id) the first time it's seen (src/quality/quality-bridge.ts) --
* the Python engine never has to pre-register rules through the CRUD
* surface just to get a quality read on an order's already-produced rows;
* 2. runs QualityService.runQuality synchronously against the rows the
* pipeline already produced (delivered, post the Python filter --
* this is an ADVISORY second opinion, not a gate, until Simon decides
* to wire per-row gating -- see quality_bridge.py's docstring for why
* that isn't done in this pass);
* 3. returns the run + any open issues.
*
* This does not replace, call, or block on the Python filter in any way --
* it is purely additive. If this route is unreachable, times out, or 500s,
* the Python side is required to fail open (never raise into the pipeline).
*/
import { NextRequest, NextResponse } from "next/server";
import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas";
import { qualityService } from "@/src/services/quality";
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
const __raw = await request.json();
const __parsed = parseBody(qualityBridgeRunSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
// Tenant-scoped write (auto-provisions baseline rules + stores a run):
// the internal service principal must stay inside its server-configured
// tenant allowlist, never a tenant named by the caller.
const guard = assertTenantMatchesVerified(request, body.tenant_id);
if (guard) return guard;
await ensureBaselineRules(qualityService, {
tenant_id: body.tenant_id,
project_id: body.project_id,
dataset_id: body.dataset_id,
});
const run = await qualityService.runQuality({
tenant_id: body.tenant_id,
project_id: body.project_id,
dataset_id: body.dataset_id,
dataset_version: body.dataset_version,
rows: body.rows,
correlation_id: body.correlation_id,
});
const issues = qualityService.listIssues({
tenant_id: body.tenant_id,
dataset_id: body.dataset_id,
status: "open",
});
return NextResponse.json({ run, issues }, { status: 201 });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-31
View File
@@ -1,31 +0,0 @@
/**
* app/api/0pt/quality/issues/route.ts
* ======================================
* quality.list_issues (GET) -- issues opened by finished quality runs
* (human-review handoff, Master-DoD §10).
*/
import { NextRequest, NextResponse } from "next/server";
import { qualityService } from "@/src/services/quality";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import type { DataQualityIssue } from "@/src/quality/quality-types";
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const issues = qualityService.listIssues({
tenant_id: tenantId,
dataset_id: searchParams.get("dataset_id") ?? undefined,
status: (searchParams.get("status") as DataQualityIssue["status"]) ?? undefined,
});
return NextResponse.json(issues);
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
@@ -1,61 +0,0 @@
/**
* app/api/0pt/quality/rules/[ruleId]/route.ts
* ===============================================
* quality.get_rule (GET) + quality.update_rule (PUT, versioned -- bumps
* `version`, records the superseded row). Cross-tenant reads are NOT_FOUND
* (no existence oracle), matching QualityService.getRule's own contract.
*/
import { NextRequest, NextResponse } from "next/server";
import { qualityService } from "@/src/services/quality";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import { z } from "zod";
const updateQualityRuleSchema = z.object({
tenant_id: z.string().min(1),
name: z.string().min(1).optional(),
expectation: z.object({ expectation_type: z.string().min(1), kwargs: z.record(z.string(), z.unknown()) }).optional(),
severity: z.enum(["info", "warning", "error", "critical"]).optional(),
status: z.enum(["active", "disabled", "superseded"]).optional(),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ ruleId: string }> }) {
try {
const { ruleId } = await params;
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const versions = searchParams.get("versions") === "true";
if (versions) {
return NextResponse.json(qualityService.getRuleVersions(ruleId, tenantId));
}
return NextResponse.json(qualityService.getRule(ruleId, tenantId));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ ruleId: string }> }) {
try {
const { ruleId } = await params;
const raw = await request.json();
const parsed = updateQualityRuleSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: "validation_error", issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) },
{ status: 400 }
);
}
const { tenant_id, ...patch } = parsed.data;
const tenantGuard = assertTenantMatchesVerified(request, tenant_id);
if (tenantGuard) return tenantGuard;
const rule = await qualityService.updateRule(ruleId, tenant_id, patch);
return NextResponse.json(rule);
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-52
View File
@@ -1,52 +0,0 @@
/**
* app/api/0pt/quality/rules/route.ts
* ====================================
* quality.create_rule (POST) + quality.list_rules (GET) -- the CRUD surface
* over QualityService's versioned rule registry (src/quality/quality-service.ts,
* Master-DoD §10). Uses the shared singleton from src/services/quality.ts,
* not a fresh `new QualityService()` per route module -- see that file's
* doc comment (and BEFORE_STATE.md §1, which found and fixed the same
* per-module-instance defect for the Source Registry).
*/
import { NextRequest, NextResponse } from "next/server";
import { parseBody, createQualityRuleSchema } from "@/src/validation/0pt-schemas";
import { qualityService } from "@/src/services/quality";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
import type { DataQualityRule } from "@/src/quality/quality-types";
export async function POST(request: NextRequest) {
try {
const __raw = await request.json();
const __parsed = parseBody(createQualityRuleSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const tenantGuard = assertTenantMatchesVerified(request, __parsed.data.tenant_id);
if (tenantGuard) return tenantGuard;
const rule = await qualityService.createRule(__parsed.data);
return NextResponse.json(rule, { 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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const rules = qualityService.listRules({
tenant_id: tenantId,
dataset_id: searchParams.get("dataset_id") ?? undefined,
status: (searchParams.get("status") as DataQualityRule["status"]) ?? undefined,
});
return NextResponse.json(rules);
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-25
View File
@@ -1,25 +0,0 @@
/**
* app/api/0pt/quality/runs/[runId]/route.ts
* =============================================
* quality.get_run (GET). Cross-tenant reads are NOT_FOUND.
*/
import { NextRequest, NextResponse } from "next/server";
import { qualityService } from "@/src/services/quality";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function GET(request: NextRequest, { params }: { params: Promise<{ runId: string }> }) {
try {
const { runId } = await params;
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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
return NextResponse.json(qualityService.getRun(runId, tenantId));
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-49
View File
@@ -1,49 +0,0 @@
/**
* app/api/0pt/quality/runs/route.ts
* ====================================
* quality.run (POST) + quality.list_runs (GET). POST executes all active
* rules for a dataset against caller-supplied rows -- runs synchronously
* when every active rule's expectation is locally executable, otherwise
* delegates to the GE backend via N0D (QualityService.runQuality never
* fabricates a result for a check it couldn't run).
*/
import { NextRequest, NextResponse } from "next/server";
import { parseBody, createQualityRunSchema } from "@/src/validation/0pt-schemas";
import { qualityService } from "@/src/services/quality";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
const __raw = await request.json();
const __parsed = parseBody(createQualityRunSchema, __raw);
if (!__parsed.ok) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const tenantGuard = assertTenantMatchesVerified(request, __parsed.data.tenant_id);
if (tenantGuard) return tenantGuard;
const run = await qualityService.runQuality(__parsed.data);
return NextResponse.json(run, { 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 tenantGuard = assertTenantMatchesVerified(request, tenantId);
if (tenantGuard) return tenantGuard;
const runs = qualityService.listRuns({
tenant_id: tenantId,
dataset_id: searchParams.get("dataset_id") ?? undefined,
});
return NextResponse.json(runs);
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
-19
View File
@@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
product: "0pt",
version: "1.0.0",
endpoints: [
"/api/0pt/sources",
"/api/0pt/datasets",
"/api/0pt/data-contracts",
"/api/0pt/quality/checks",
"/api/0pt/quality/reports",
"/api/0pt/features/views",
"/api/0pt/documents/capture",
"/api/0pt/documents/{documentId}/extraction",
],
});
}

Some files were not shown because too many files have changed in this diff Show More