Compare commits

..
Author SHA1 Message Date
siax-bot 2a253bdb66 Merge pull request 'fix(security): ta bort död GitHub Actions-CI (SEC-25)' (#547) from fix/sec25-remove-github-workflows into main
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 16s
CI (SIAX Cloud) / security (push) Successful in 28s
Security Scan (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 1m6s
CI (SIAX Cloud) / sonar (push) Successful in 2m10s
masterplan-lock / check (push) Successful in 13s
CI (SIAX Cloud) / integration (push) Successful in 11m8s
CI (SIAX Cloud) / quality (push) Successful in 11m23s
Deploy to Coolify / deploy (push) Failing after 10m16s
2026-11-10 09:41:12 +00:00
admin 6d16589878 fix(security): ta bort död GitHub Actions-CI (SEC-25)
masterplan-lock / check (push) Successful in 12s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Successful in 26s
masterplan-lock / check (pull_request) Successful in 23s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 35s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 1m7s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m59s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m23s
Varför: .github/workflows/ci.yml återställdes av recovery-commit 3d2fc6f
('restore source removed by production documentation merge'). 0pt är
Gitea-first: den aktiva pipelinen är .gitea/workflows/ci.yml, och
.github-varianten är en divergerad kopia som blir körbar först om någon
registrerar en GitHub-runner. Doctor-kontrollen SEC-25 flaggar den.

Ändring: filen borttagen. Ingen funktionalitet förlorad — Gitea-varianten
är kanalisk och orörd.

Verifiering: doctor --repo . -> SEC-25 PASS.
2026-11-10 10:29:22 +01:00
admin 6fa5408cc1 Merge pull request 'docs(runbook): stäng DOC-07 med befintligt, verkligt restore-bevis' (#546) from docs/doc07-restore-drill into main
masterplan-lock / check (push) Successful in 11s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 20s
CI (SIAX Cloud) / security (push) Successful in 41s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 53s
Security Scan (SIAX Cloud) / security (push) Successful in 45s
CI (SIAX Cloud) / integration (push) Successful in 2m8s
CI (SIAX Cloud) / sonar (push) Successful in 2m5s
CI (SIAX Cloud) / quality (push) Successful in 3m30s
Deploy to Coolify / deploy (push) Failing after 11m33s
2026-11-10 09:11:42 +00:00
admin a87eb534d4 docs(doctor): stäng DOC-07 med befintligt, verkligt restore-bevis
masterplan-lock / check (push) Successful in 15s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 39s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 38s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m47s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m48s
Varför: DOC-07 kräver en otvetydig rad om BEVISAD restore med datum i
docs/runbook/backup-restore.md. Repot har redan en genomförd, dokumenterad
restore-övning — det som saknades var den kanoniska raden.

Ändring: en rad som namnger datum, källa och vad som faktiskt utfördes. Inget
påstående som inte redan står i repots egen evidens.

Verifiering: doctor --repo . -> DOC-07 PASS.
2026-11-10 10:03:14 +01:00
admin f88d3b4161 Merge pull request 'fix(security): gitleaks-fingerprints som inte flaggar sig själva' (#545) from fix/gitleaksignore-b2 into main
Deploy to Coolify / deploy (push) Canceled after 0s
CI (SIAX Cloud) / quality (push) Canceled after 0s
CI (SIAX Cloud) / integration (push) Canceled after 0s
CI (SIAX Cloud) / security (push) Canceled after 0s
CI (SIAX Cloud) / sonar (push) Canceled after 0s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Canceled after 0s
Security Scan (SIAX Cloud) / security (push) Canceled after 0s
masterplan-lock / check (push) Successful in 16s
2026-11-10 08:59:50 +00:00
admin 3e186591a9 fix(security): gitleaks-fingerprints som inte flaggar sig själva
masterplan-lock / check (push) Successful in 18s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 11s
CI (SIAX Cloud) / security (pull_request) Failing after 15s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 1m56s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m2s
CI (SIAX Cloud) / quality (pull_request) Successful in 12m50s
Varför: REPO-15 fällde repot igen. Två orsaker, båda strukturella:
(1) gitleaks skannar historiken, så samma falska positiv måste pinnas per
commit som inför den — nya commits gav nya träffar; (2) .gitleaksignore
skannas själv, så en kommentar som återgav det flaggade värdet ordagrant
gjorde att filen flaggade sig själv.

Ändring: .gitleaksignore skriven om med en post per commit:fil:regel:rad för
samtliga verifierade falska positiva, och kommentarer som beskriver fyndet
utan att återge något värde. Inga regel- eller globundantag — ett framtida
riktigt fynd fälls fortfarande.

Verifiering: gitleaks detect -s . -> no leaks found.
2026-11-10 09:54:33 +01:00
admin 5468ef92f0 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (1e5fafce1a)' (#544) from fix/prod-evidence-2026-09-29 into main
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 5s
masterplan-lock / check (push) Successful in 32s
CI (SIAX Cloud) / security (push) Successful in 38s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 38s
Security Scan (SIAX Cloud) / security (push) Successful in 29s
CI (SIAX Cloud) / sonar (push) Successful in 1m51s
CI (SIAX Cloud) / quality (push) Successful in 2m31s
CI (SIAX Cloud) / integration (push) Successful in 10m0s
Deploy to Coolify / deploy (push) Failing after 10m11s
2026-11-10 08:33:23 +00:00
siax-bot d4a9ffee62 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-1e5fafce1a
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 30s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 46s
CI (SIAX Cloud) / integration (pull_request) Successful in 55s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m2s
2026-11-10 09:32:02 +01:00
admin 1e5fafce1a Merge pull request #543
masterplan-lock / check (push) Successful in 6s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 7s
CI (SIAX Cloud) / integration (push) Failing after 18s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 34s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 45s
CI (SIAX Cloud) / sonar (push) Successful in 1m28s
Deploy to Coolify / deploy (push) Failing after 9m58s
CI (SIAX Cloud) / quality (push) Successful in 10m31s
2026-11-10 08:22:43 +00:00
SIAX Estate 67b0a205b7 fix(estate): återställ main efter massradering i docs(production)-svepet
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 11s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 48s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m15s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m38s
Massraderingscommit: a4189333af (docs(production): standardiserad produktionsberedskap och gap-analys)
Återställer trädet till föräldern fb072d47c7 (959 filer) och behåller
efterföljande dokumentationsfiler.
2026-11-10 09:22:37 +01:00
siax-bot 576ae1585f Merge pull request 'fix(security): smala gitleaks-fingerprints för verifierade falska positiver' (#538) from chore/gitleaksignore-repo15 into main
CI (SIAX Cloud) / security (push) Successful in 31s
Security Scan (SIAX Cloud) / security (push) Successful in 36s
CI (SIAX Cloud) / integration (push) Successful in 1m1s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 1m12s
CI (SIAX Cloud) / sonar (push) Successful in 1m19s
masterplan-lock / check (push) Successful in 1m40s
CI (SIAX Cloud) / quality (push) Successful in 2m10s
Deploy to Coolify / deploy (push) Successful in 4m20s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 4m37s
2026-11-10 06:20:33 +00:00
siax-bot 1029a4c485 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (a6c605ea7f)' (#541) from fix/prod-evidence-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 12s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / quality (push) Canceled after 42s
CI (SIAX Cloud) / integration (push) Canceled after 42s
CI (SIAX Cloud) / security (push) Canceled after 42s
CI (SIAX Cloud) / sonar (push) Canceled after 41s
masterplan-lock / check (push) Successful in 54s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m4s
Deploy to Coolify / deploy (push) Successful in 2m20s
2026-11-10 06:19:50 +00:00
siax-bot e0fb148fba fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-a6c605ea7f
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
masterplan-lock / check (push) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 40s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-10 05:30:21 +01:00
admin a6c605ea7f Merge pull request 'fix(recovery): restore 0pt source and isolate integration writes' (#539) from codex/operator-source-recovery-20261110 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 18s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m19s
CI (SIAX Cloud) / quality (push) Successful in 1m28s
Deploy to Coolify / deploy (push) Successful in 2m15s
CI (SIAX Cloud) / sonar (push) Successful in 2m17s
CI (SIAX Cloud) / integration (push) Successful in 9m46s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
masterplan-lock / check (pull_request) Successful in 11s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m47s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m7s
2026-11-10 02:55:08 +00:00
admin 0cac0266e0 test(safety): require an isolated opt-in target for ST0RE integration
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m13s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m15s
2026-11-10 03:49:33 +01:00
admin 3d2fc6f913 fix(recovery): restore source removed by production documentation merge 2026-11-10 03:42:10 +01:00
admin 39fc5e3ce3 fix(security): smala gitleaks-fingerprints för verifierade falska positiver
Varför: REPO-15 (BLOCK) fällde repot på fynd i historiken. Varje fynd öppnades
och lästes; samtliga är syntetiska testvärden, dokumentationsplatshållare eller
tredjepartsinnehåll — ingen verklig hemlighet. Se kommentarerna i filen.

Ändring: .gitleaksignore med en post per exakt förekomst i formatet
<commit>:<fil>:<regel>:<rad>. Inga regel- eller globundantag, så ett framtida
riktigt fynd fälls fortfarande.

Verifiering: gitleaks detect -s . -> no leaks found; doctor --repo . -> REPO-15 PASS.
2026-11-10 03:09:20 +01:00
admin 585bdd4b44 docs(production): standardiserad produktionsberedskap och gap-analys 2026-11-10 02:07:52 +00:00
admin a4189333af docs(production): standardiserad produktionsberedskap och gap-analys
Lägger docs/production-readiness.md och siax/production-gaps.json enligt SIAX Master Definition of Done. Underlag: git-, kod- och live-evidens 2026-11-11. Nivån är evidensbaserad, inte ett leveransgodkännande.
2026-11-10 03:07:36 +01:00
admin fb072d47c7 docs(status): spegla STATUS.md i docs/ för produktionsaudit
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / integration (push) Failing after 37s
CI (SIAX Cloud) / security (push) Successful in 1m1s
CI (SIAX Cloud) / sonar (push) Successful in 1m10s
masterplan-lock / check (push) Successful in 1m3s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m8s
Deploy to Coolify / deploy (push) Successful in 2m39s
CI (SIAX Cloud) / quality (push) Successful in 10m12s
2026-11-10 02:21:43 +01:00
admin e7cfdf47f1 Merge pull request #536 (manually-merged)
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 14s
masterplan-lock / check (push) Successful in 22s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / security (push) Successful in 51s
CI (SIAX Cloud) / integration (push) Successful in 1m57s
CI (SIAX Cloud) / sonar (push) Successful in 2m21s
Deploy to Coolify / deploy (push) Successful in 3m5s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m53s
CI (SIAX Cloud) / quality (push) Successful in 10m45s
2026-11-10 01:55:30 +01:00
siax-bot dccdfed9bd fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-925f5ca2fd
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 24s
masterplan-lock / check (push) Successful in 10s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m53s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m4s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m31s
2026-11-10 01:30:33 +01:00
admin 925f5ca2fd Merge pull request #535
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 11s
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m44s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m35s
Deploy to Coolify / deploy (push) Successful in 1m40s
CI (SIAX Cloud) / integration (push) Successful in 10m31s
CI (SIAX Cloud) / quality (push) Successful in 12m23s
2026-11-10 00:01:01 +00:00
admin 2ea4062d09 chore: adopt SIAX Golden Standard docs and configuration
masterplan-lock / check (push) Successful in 14s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 14s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 37s
CI (SIAX Cloud) / security (pull_request) Successful in 38s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m23s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m28s
siax-scaffold genererade saknade standardartefakter (arkitektur, säkerhet,
ändringslogg, runbooks, CI, devcontainer, licens m.m.) ur repots faktiska
filer. DOC-07 lämnas avsiktligt röd till en verklig restore bevisats.
2026-11-10 00:55:03 +01:00
admin 6ae98c7034 Merge pull request 'chore(doctor): undanta genererade artefakter och spegelprosa från REPO-01' (#534) from chore/doctor-exemptions-repo01 into main
masterplan-lock / check (push) Successful in 11s
CI (SIAX Cloud) / security (push) Successful in 48s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 58s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / sonar (push) Successful in 1m57s
CI (SIAX Cloud) / quality (push) Canceled after 2m26s
CI (SIAX Cloud) / integration (push) Canceled after 2m22s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m16s
Deploy to Coolify / deploy (push) Successful in 3m7s
2026-11-09 23:46:45 +00:00
admin 54ff8a3958 ci: re-trigger efter serveromstart (statusuppdatering avbröts) 2026-11-10 00:41:53 +01:00
admin 869218fb8b Merge pull request #533
Deploy to Coolify / deploy (push) Canceled after 0s
CI (SIAX Cloud) / quality (push) Canceled after 0s
CI (SIAX Cloud) / integration (push) Canceled after 0s
CI (SIAX Cloud) / security (push) Canceled after 0s
CI (SIAX Cloud) / sonar (push) Canceled after 0s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Canceled after 0s
Security Scan (SIAX Cloud) / security (push) Canceled after 0s
masterplan-lock / check (push) Successful in 9s
2026-11-09 23:25:44 +00:00
admin 1e79d0c4cf chore(doctor): exempt generated artifacts and mirror prose from REPO-01
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 30s
CI (SIAX Cloud) / security (pull_request) Successful in 52s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m46s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m54s
Varför: REPO-01 (inga github.com-remotes eller -referenser i json/md) stod som
BLOCK även efter att de felaktiga lokala github-remoterna tagits bort. De
kvarvarande träffarna var av två slag: genererade artefakter (SBOM,
release-artefakter, package-lock.json) som listar tredjepartsberoendens
metadata, och dokumentationsprosa som antingen beskriver GitHub som read-only
spegel av kanoniska git.siax.io, länkar tredjepartsprojekt, eller citerar
GitHubs egna produkt-/inställningssidor i historiska driftsanteckningar.

Ändring: ny .siax-doctor.json med två smala REPO-01-undantag — ett för de
genererade artefakterna, ett för de namngivna dokumentationsfilerna — med
motivering per kategori. Där en dokumentationslänk pekade på en SIAX-repo via
GitHub-spegeln och raden inte handlade om spegeln i sig, är länken omskriven
till kanoniska https://git.siax.io/. Inga genererade filer är redigerade.

Verifiering: node packages/doctor/src/doctor.mjs --repo . ger REPO-01 PASS
("N träff(ar) undantagna via .siax-doctor.json").
2026-11-10 00:22:32 +01:00
admin 72d5876d23 docs(policy): SIAX Agent Execution Policy i AGENTS.md + CLAUDE.md
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 10s
masterplan-lock / check (pull_request) Successful in 11s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m17s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m52s
masterplan-lock / check (push) Successful in 5s
2026-11-10 00:18:20 +01:00
admin 596ca3f90e docs(production): standardiserad produktionsberedskap och gap-analys
CI (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 17s
Security Scan (SIAX Cloud) / security (push) Successful in 31s
CI (SIAX Cloud) / integration (push) Successful in 1m5s
CI (SIAX Cloud) / sonar (push) Successful in 1m10s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m29s
Deploy to Coolify / deploy (push) Successful in 1m35s
CI (SIAX Cloud) / quality (push) Successful in 12m2s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 10s
masterplan-lock / check (pull_request) Successful in 10s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m1s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m35s
2026-11-09 22:57:08 +00:00
admin 66a7e253eb docs(production): standardiserad produktionsberedskap och gap-analys
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
masterplan-lock / check (push) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m59s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m30s
Lägger docs/production-readiness.md och siax/production-gaps.json enligt SIAX Master Definition of Done. Underlag: git-, kod- och live-evidens 2026-11-09. Nivån är evidensbaserad, inte ett leveransgodkännande.
2026-11-09 23:56:19 +01:00
admin a4e6d7d8e2 Merge pull request 'chore(manifest): registrera saknade surface-poster (surfaces-fasen)' (#530) from chore/surface-posts-20261110 into main
CI (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 23s
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (push) Successful in 37s
CI (SIAX Cloud) / sonar (push) Successful in 1m23s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m45s
Deploy to Coolify / deploy (push) Successful in 3m0s
CI (SIAX Cloud) / quality (push) Canceled after 6m41s
CI (SIAX Cloud) / integration (push) Canceled after 6m39s
2026-11-09 22:49:57 +00:00
admin 416d00fc46 chore(manifest): registrera saknade surface-poster (surfaces-fasen)
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
masterplan-lock / check (pull_request) Successful in 25s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 35s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m56s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m18s
Varje capability deklarerar i `surfaces[]` vilka ytor den ska ha (api, cli,
mcp, ui, job, sdk). Fram till nu saknades `siax.surface`-poster för många av
dem, vilket gjorde `siax-doctor surfaces` röd över estaten.

Denna commit lägger posterna. ÄRLIGHETSREGEL: status sätts till `live` ENDAST
där exponeringen kan påvisas i repot — operationId finns i committad OpenAPI,
i genererad SDK, eller i ett CLI-/MCP-paket. Övriga poster skrivs som
`planned`: deklarationen finns kvar men posten påstår inte att ytan är byggd.
Ingen yta har påhittats och ingen deklaration har tagits bort.
2026-11-09 23:40:39 +01:00
admin d3b1964d3a feat(capabilities): registrera capability-ägare med verifierat kontrakt (#529)
masterplan-lock / check (push) Successful in 14s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 18s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m31s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m44s
Deploy to Coolify / deploy (push) Successful in 2m9s
CI (SIAX Cloud) / integration (push) Successful in 10m12s
CI (SIAX Cloud) / quality (push) Successful in 11m26s
2026-11-09 21:25:08 +00:00
Claude Code 59c0e008f5 Merge pull request #528
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m39s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m58s
Deploy to Coolify / deploy (push) Successful in 2m23s
CI (SIAX Cloud) / integration (push) Successful in 9m55s
CI (SIAX Cloud) / quality (push) Successful in 10m12s
masterplan-lock / check (push) Successful in 7s
2026-11-09 22:01:44 +01:00
admin 20d66ea40d feat(capabilities): bind operationId for API-surface capability records
masterplan-lock / check (push) Successful in 8s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
masterplan-lock / check (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
CI (SIAX Cloud) / integration (pull_request) Successful in 37s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m26s
Records with an api surface but no operationId cannot be attested against the
implementation (doctor CAP-OPID-MISSING). Each operationId here is taken verbatim
from this repo's own contract file and matches the capability one-to-one; no
operationId was guessed.
2026-11-09 21:48:55 +01:00
Claude Code 7ce87ffa01 Merge pull request #527
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 13s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / integration (push) Successful in 45s
CI (SIAX Cloud) / sonar (push) Successful in 1m28s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m35s
Deploy to Coolify / deploy (push) Successful in 1m44s
CI (SIAX Cloud) / quality (push) Successful in 10m24s
2026-11-09 21:33:40 +01:00
admin d81325f27c Merge pull request 'feat(auth): dual-accept CL0UD workload identity on the machine routes' (#525) from feat/workload-identity-dual-accept-20261108 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 31s
CI (SIAX Cloud) / sonar (push) Successful in 1m38s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m58s
Deploy to Coolify / deploy (push) Successful in 2m2s
CI (SIAX Cloud) / quality (push) Successful in 10m14s
CI (SIAX Cloud) / integration (push) Successful in 10m18s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 8s
masterplan-lock / check (push) Successful in 11s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 33s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m58s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m43s
2026-11-09 17:40:31 +00:00
admin d40360aabc feat(auth): dual-accept CL0UD workload identity on the machine routes
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m5s
The internal machine routes (`quality/bridge-run`, `canonical/facts`,
`internal/evidence-outbox/dispatch`, `internal/er/candidates`,
`search/definitions`) trusted a shared `X-Internal-Token` with NO tenant
authority: middleware let it through without setting any verified tenant, so
`assertTenantMatchesVerified` was a no-op and a caller-chosen `tenant_id` in
the body was authoritative.

This closes that gap while keeping the engine working during the transition
to CL0UD workload tokens:

- `src/auth/internal-service-tenant.ts` — the server-configured
  `INTERNAL_SERVICE_TENANTS` scope, a scope header that only middleware may
  set, and a parser that strips/ignores client values.
- `src/auth/cl0ud-workload.ts` — a real `jose`/JWKS verifier for CL0UD
  workload JWTs (`iss`/`aud`/`exp`), deriving `tenant_id` and `capabilities`
  from verified claims only. Fail-closed when a token is presented but the
  issuer/JWKS/audience/tenant claim does not verify.
- `middleware.ts` — strips every `x-0pt-verified-*` header on all forwarded
  paths. Legacy internal tokens get the single configured tenant as scope
  (an ambiguous multi-tenant list is refused with 401 instead of widened);
  a Bearer CL0UD workload token is verified and its claims become the
  verified tenant/subject/capabilities.
- `src/http/tenant-guard.ts` — enforces the service scope on tenant-scoped
  writes (deny when the tenant is outside the scope) and adds
  `assertWorkloadCapabilities` for capability-scoped principals.
- `quality/bridge-run` now calls `assertTenantMatchesVerified` like
  `canonical/facts` already did.

Tests: `tests/auth/workload-identity-dual-accept.test.ts` (10 cases) covers
forged-header stripping, ambiguous-scope refusal, wrong audience, missing
tenant claim, cross-tenant denial and missing-capability denial.

Real CL0UD provisioning (service accounts + published JWKS) is still
required before a workload token exists in production; until then the
legacy token path is the only one that authenticates, now tenant-scoped.
2026-11-09 18:34:34 +01:00
siax-bot c6a5102a2a fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-62517aca49
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
masterplan-lock / check (pull_request) Successful in 29s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 38s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m27s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m46s
2026-11-09 17:30:20 +01:00
admin 62517aca49 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#523) from fix/prod-restore-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 10s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / quality (push) Successful in 42s
CI (SIAX Cloud) / sonar (push) Successful in 53s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 59s
Deploy to Coolify / deploy (push) Successful in 49s
CI (SIAX Cloud) / integration (push) Successful in 9m56s
2026-11-09 16:12:42 +00:00
admin db26daa979 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 20s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 31s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m27s
2026-11-09 16:12:31 +00:00
admin c532cedb5e Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (38ae3ddf0b)' (#522) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 15s
CI (SIAX Cloud) / sonar (push) Canceled after 15s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 59s
Deploy to Coolify / deploy (push) Successful in 1m12s
2026-11-09 16:12:27 +00:00
siax-bot 40202178a3 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-38ae3ddf0b
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Failing after 18s
masterplan-lock / check (pull_request) Failing after 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m19s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m27s
2026-11-09 13:30:20 +01:00
admin 38ae3ddf0b Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#521) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / quality (push) Failing after 9s
CI (SIAX Cloud) / security (push) Failing after 9s
CI (SIAX Cloud) / sonar (push) Failing after 9s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / integration (push) Successful in 1m6s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 59s
Deploy to Coolify / deploy (push) Successful in 1m23s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m12s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m27s
2026-11-09 12:14:54 +00:00
admin a7a4295043 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 20s
CI (SIAX Cloud) / quality (pull_request) Failing after 20s
CI (SIAX Cloud) / security (pull_request) Failing after 20s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m18s
2026-11-09 12:14:42 +00:00
admin f1d47ffb2e Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (b1ae910d64)' (#520) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / quality (push) Canceled after 15s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 14s
CI (SIAX Cloud) / sonar (push) Canceled after 14s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m5s
Deploy to Coolify / deploy (push) Successful in 1m20s
2026-11-09 12:14:39 +00:00
siax-bot 8e7c228bd2 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-b1ae910d64
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 42s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m18s
2026-11-09 09:32:10 +01:00
admin b1ae910d64 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#519) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / security (push) Failing after 13s
CI (SIAX Cloud) / sonar (push) Failing after 13s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 12s
CI (SIAX Cloud) / integration (push) Failing after 20s
Security Scan (SIAX Cloud) / security (push) Failing after 21s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 1m32s
Deploy to Coolify / deploy (push) Failing after 21s
CI (SIAX Cloud) / quality (push) Successful in 10m31s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
masterplan-lock / check (push) Failing after 20s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 37s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m8s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m34s
2026-11-09 07:50:38 +00:00
admin 91f270ea19 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / integration (pull_request) Failing after 1s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Failing after 1s
CI (SIAX Cloud) / security (pull_request) Failing after 1s
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 10s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 20s
CI (SIAX Cloud) / quality (pull_request) Successful in 57s
2026-11-09 07:50:27 +00:00
admin ac3a95f320 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (419c2d3f18)' (#518) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 8s
CI (SIAX Cloud) / Component conformance (blocking) (push) Failing after 8s
Security Scan (SIAX Cloud) / security (push) Failing after 8s
CI (SIAX Cloud) / quality (push) Failing after 9s
CI (SIAX Cloud) / security (push) Failing after 15s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 15s
Deploy to Coolify / deploy (push) Failing after 1m30s
2026-11-09 07:50:23 +00:00
siax-bot fab0a53573 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-419c2d3f18
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-09 05:30:19 +01:00
admin 419c2d3f18 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#517) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 11s
Security Scan (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / sonar (push) Successful in 1m11s
CI (SIAX Cloud) / integration (push) Successful in 1m11s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 2m47s
CI (SIAX Cloud) / quality (push) Successful in 10m9s
Deploy to Coolify / deploy (push) Failing after 10m31s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
masterplan-lock / check (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 41s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m20s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m4s
2026-11-09 03:31:40 +00:00
admin e96e015722 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 3s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m11s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m18s
2026-11-09 03:31:28 +00:00
admin 344decd285 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (ebfa17028d)' (#516) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 10s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 14s
CI (SIAX Cloud) / sonar (push) Canceled after 13s
Security Scan (SIAX Cloud) / security (push) Canceled after 13s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m45s
Deploy to Coolify / deploy (push) Successful in 1m52s
2026-11-09 03:31:25 +00:00
siax-bot 32cd714126 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-ebfa17028d
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 3s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 40s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m35s
2026-11-09 01:30:29 +01:00
admin ebfa17028d Merge pull request 'docs(identity): 0pt workload identity — målbild, design och blockerare' (#502) from docs/workload-identity-20261107 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 23s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / sonar (push) Successful in 1m33s
CI (SIAX Cloud) / quality (push) Successful in 1m43s
Deploy to Coolify / deploy (push) Successful in 2m41s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m42s
CI (SIAX Cloud) / integration (push) Successful in 9m56s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m47s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m33s
2026-11-08 23:38:28 +00:00
admin 5a8d26eee6 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#515) from fix/prod-restore-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / sonar (push) Successful in 1m47s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m20s
Deploy to Coolify / deploy (push) Successful in 1m29s
CI (SIAX Cloud) / integration (push) Successful in 9m49s
CI (SIAX Cloud) / quality (push) Successful in 10m22s
2026-11-08 23:21:42 +00:00
admin d301f6e0cc Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 43s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m8s
2026-11-08 23:21:31 +00:00
admin 85de12fdfe Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (6b57f7cb9d)' (#514) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 17s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m24s
Deploy to Coolify / deploy (push) Successful in 1m29s
2026-11-08 23:21:27 +00:00
siax-bot 966a36f898 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-6b57f7cb9d
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m49s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
2026-11-08 21:30:19 +01:00
admin 6b57f7cb9d Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#513) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / integration (push) Successful in 28s
CI (SIAX Cloud) / sonar (push) Successful in 1m1s
Security Scan (SIAX Cloud) / security (push) Successful in 1m17s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m26s
Deploy to Coolify / deploy (push) Successful in 1m11s
CI (SIAX Cloud) / quality (push) Successful in 10m3s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m25s
2026-11-08 19:36:36 +00:00
admin c15b4088c3 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / security (pull_request) Successful in 43s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m47s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-08 19:36:24 +00:00
admin fd73f24193 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (72d6d1e8d1)' (#512) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 14s
CI (SIAX Cloud) / sonar (push) Canceled after 14s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 57s
Deploy to Coolify / deploy (push) Successful in 1m17s
2026-11-08 19:36:21 +00:00
siax-bot 5795b3503f fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-72d6d1e8d1
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
masterplan-lock / check (push) Successful in 25s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 26s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m30s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m33s
2026-11-08 17:30:22 +01:00
admin 72d6d1e8d1 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#511) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 1m5s
CI (SIAX Cloud) / integration (push) Successful in 1m7s
CI (SIAX Cloud) / sonar (push) Successful in 1m18s
CI (SIAX Cloud) / quality (push) Successful in 1m39s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 50s
Deploy to Coolify / deploy (push) Successful in 43s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m5s
2026-11-08 15:42:04 +00:00
admin 1b94cd7c80 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 28s
CI (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 56s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m55s
2026-11-08 15:41:53 +00:00
admin 9dcf3c24fa Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (5010a6853b)' (#510) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 13s
CI (SIAX Cloud) / sonar (push) Canceled after 13s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m1s
Deploy to Coolify / deploy (push) Successful in 2m16s
2026-11-08 15:41:49 +00:00
siax-bot a0eba858ab fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-5010a6853b
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-08 13:30:18 +01:00
admin 5010a6853b Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#509) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / sonar (push) Successful in 1m20s
CI (SIAX Cloud) / integration (push) Successful in 1m21s
CI (SIAX Cloud) / quality (push) Successful in 1m47s
Deploy to Coolify / deploy (push) Successful in 2m36s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m43s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / quality (pull_request) Successful in 53s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m13s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 1m13s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m50s
2026-11-08 11:57:40 +00:00
admin b25c164837 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / integration (pull_request) Failing after 0s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m36s
2026-11-08 11:57:29 +00:00
admin be0acfd12c Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (f5600a25b9)' (#508) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 14s
CI (SIAX Cloud) / sonar (push) Canceled after 13s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 13s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m45s
Deploy to Coolify / deploy (push) Successful in 1m49s
2026-11-08 11:57:26 +00:00
siax-bot 5a56cc9a6e fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-f5600a25b9
CI (SIAX Cloud) / integration (pull_request) Failing after 0s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 11s
masterplan-lock / check (push) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / quality (pull_request) Successful in 40s
2026-11-08 05:30:28 +01:00
admin f5600a25b9 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#507) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 13s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / sonar (push) Successful in 1m3s
CI (SIAX Cloud) / integration (push) Successful in 1m14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 41s
Deploy to Coolify / deploy (push) Successful in 1m21s
CI (SIAX Cloud) / quality (push) Successful in 10m41s
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 3s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (pull_request) Failing after 40s
CI (SIAX Cloud) / security (pull_request) Failing after 1m43s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m2s
2026-11-08 03:58:42 +00:00
admin d85f5ae559 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 11s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / integration (pull_request) Successful in 46s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m18s
2026-11-08 03:58:30 +00:00
admin 246589bde8 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (9aef3cd97f)' (#506) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 17s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m39s
Deploy to Coolify / deploy (push) Successful in 1m42s
2026-11-08 03:58:27 +00:00
siax-bot fdba72bd35 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-9aef3cd97f
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (pull_request) Successful in 43s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m19s
2026-11-08 01:30:28 +01:00
admin 9aef3cd97f Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#505) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / integration (push) Successful in 27s
CI (SIAX Cloud) / sonar (push) Successful in 1m11s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m25s
Deploy to Coolify / deploy (push) Successful in 1m37s
CI (SIAX Cloud) / quality (push) Successful in 10m15s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 9s
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 40s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m47s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
2026-11-07 23:21:36 +00:00
admin 34ef39e5f5 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 12s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
masterplan-lock / check (pull_request) Successful in 30s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m52s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m18s
2026-11-07 23:21:24 +00:00
admin 43ec05ade2 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (a0e701efc8)' (#504) from fix/prod-evidence-2026-09-29 into main
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 13s
CI (SIAX Cloud) / security (push) Canceled after 12s
CI (SIAX Cloud) / sonar (push) Canceled after 12s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 12s
Security Scan (SIAX Cloud) / security (push) Canceled after 13s
masterplan-lock / check (push) Successful in 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m45s
Deploy to Coolify / deploy (push) Successful in 1m50s
2026-11-07 23:21:21 +00:00
siax-bot be6b92fb82 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-a0e701efc8
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m28s
2026-11-07 21:30:19 +01:00
admin a0e701efc8 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#493) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m13s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 3m44s
Deploy to Coolify / deploy (push) Successful in 3m34s
CI (SIAX Cloud) / integration (push) Successful in 10m0s
CI (SIAX Cloud) / quality (push) Successful in 10m1s
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 30s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 59s
2026-11-07 19:10:33 +00:00
admin 48fbe6b184 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m55s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m10s
2026-11-07 19:10:22 +00:00
admin b90cded2d6 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (b2499ace79)' (#500) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 16s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m26s
Deploy to Coolify / deploy (push) Successful in 1m47s
2026-11-07 19:10:17 +00:00
admin aba909c29a Merge branch 'main' into fix/prod-evidence-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 10s
masterplan-lock / check (pull_request) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 49s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m6s
2026-11-07 19:10:04 +00:00
admin e00c7b0fc5 Merge pull request 'docs(runbook): legacy-instansaudit server5 (0pt + L0RE)' (#501) from docs/legacy-instances-20261107 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / quality (push) Successful in 42s
CI (SIAX Cloud) / sonar (push) Successful in 56s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m25s
Deploy to Coolify / deploy (push) Successful in 2m17s
CI (SIAX Cloud) / integration (push) Successful in 10m2s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m45s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m26s
2026-11-07 17:34:12 +00:00
admin de76f3795b Merge pull request 'fix(canonical): RFC3339-serialisering av timestamptz' (#498) from fix/canonical-iso-timestamps-20261107 into main
CI (SIAX Cloud) / quality (push) Canceled after 5s
CI (SIAX Cloud) / integration (push) Canceled after 5s
CI (SIAX Cloud) / security (push) Canceled after 5s
CI (SIAX Cloud) / sonar (push) Canceled after 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 4s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Canceled after 5s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m1s
Deploy to Coolify / deploy (push) Successful in 1m12s
2026-11-07 17:34:07 +00:00
admin 3421748f07 Merge branch 'main' into docs/legacy-instances-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
masterplan-lock / check (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
masterplan-lock / check (push) Successful in 34s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m18s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m29s
2026-11-07 17:19:41 +00:00
admin 9ac681ee27 Merge branch 'main' into fix/canonical-iso-timestamps-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 10s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m0s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m27s
2026-11-07 17:19:39 +00:00
admin cdbb10b8e3 Merge pull request 'test(lint): typa idempotency-store (fixar quality-gaten som blockerar alla PR)' (#503) from fix/lint-siax-access-idempotency into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 26s
Security Scan (SIAX Cloud) / security (push) Successful in 37s
CI (SIAX Cloud) / sonar (push) Successful in 1m22s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m48s
Deploy to Coolify / deploy (push) Successful in 2m55s
CI (SIAX Cloud) / integration (push) Successful in 9m58s
CI (SIAX Cloud) / quality (push) Successful in 10m34s
2026-11-07 17:19:35 +00:00
SIAX Agent 6bbbcf75d4 test(lint): typa idempotency-store i stället för any (blockerar quality-gaten)
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m22s
tests/siax-access/idempotency.test.ts hade fyra @typescript-eslint/no-explicit-any
vilket fick quality-jobbet att exita 1 (133 testfiler gröna men lint röd) och
därmed blockerade alla PR:er. Använder exporterade IdempotencyStore<unknown,
{x:number}>; eslint rent, 7/7 tester gröna.
2026-11-07 18:15:04 +01:00
SIAX Agent 8741169259 docs(identity): 0pt workload identity — målbild, integrationsdesign och blockerare
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m9s
Nuläge (delad INTERNAL_TOKEN + serverkonfigurerad tenant-allowlist) mot CL0UD:s
redan existerande service-account/workload-token-maskineri (aud:0pt, 10 min,
capabilities). Design: verifiering före route-logik, tenant från claim,
capability per route, dual-accept under övergång, negativa tester i samma svit.

Provisionering är blockerad: /account/service-accounts kräver operator-session
(307 -> /login) och Zitadel admin-PAT är invalid (AUTH-7fs1e).
2026-11-07 18:14:23 +01:00
admin 58dca92d9f Merge pull request 'fix(sql): 023 grants för canonical-tabellerna till app-rollen' (#499) from fix/canonical-grants-20261107 into main
masterplan-lock / check (push) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 18s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / sonar (push) Successful in 1m7s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m6s
Deploy to Coolify / deploy (push) Successful in 2m9s
CI (SIAX Cloud) / quality (push) Canceled after 7m32s
CI (SIAX Cloud) / integration (push) Canceled after 7m32s
2026-11-07 17:12:03 +00:00
admin 7ca7b98fb2 Merge branch 'main' into docs/legacy-instances-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
masterplan-lock / check (push) Successful in 26s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 29s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m30s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m52s
2026-11-07 17:07:45 +00:00
admin b269a05538 Merge branch 'main' into fix/canonical-grants-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 12s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m28s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m30s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m15s
2026-11-07 17:07:44 +00:00
admin 1b7c6c2de9 Merge branch 'main' into fix/canonical-iso-timestamps-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 11s
masterplan-lock / check (pull_request) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 1m37s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m38s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m10s
2026-11-07 17:07:42 +00:00
SIAX Agent b7627a3a31 docs(runbook): legacy-instansaudit server5 (0pt + L0RE) och disposition
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 3s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m12s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
0pt legacy: 32 kanoniska facts exporterade och återinförda via API (replay
duplicates=32), dump till server11, api/web stoppade med restart=no, pg kvar
för forensik. L0RE-orphan refererad till PR #485. Ingen databas/volymer raderade.
2026-11-07 17:37:33 +01:00
SIAX Agent afd0b4ec46 fix(canonical): serialisera timestamptz som RFC3339 (ISO-8601)
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m11s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m1s
queryFacts använde String(Date) -> 'Sat Nov 07 2026 15:00:00 GMT+0000 (...)',
vilket L0RE:s canonical-projection avvisar ('must be RFC3339 date-time').
Inför iso()-helper: Date -> toISOString(), annars String(). Gäller
observed_at, valid_from, valid_to, created_at.
2026-11-07 17:30:40 +01:00
SIAX Agent 4dbabd14ac fix(sql): 023 grants for canonical tables to app-role 0pt_app
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m7s
022 skapade tabellerna som 0pt_edge; appen kör som 0pt_app och fick
'permission denied'. Övriga 0pt-tabeller har explicita grants (inga default
ACL), så kanoniska tabeller behöver samma. Idempotent och rollskyddad; RLS
(FORCE) gäller fortfarande för 0pt_app.
2026-11-07 17:29:03 +01:00
admin b2499ace79 Merge pull request #497: idempotent 022 canonical facts
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m11s
Deploy to Coolify / deploy (push) Successful in 1m14s
CI (SIAX Cloud) / sonar (push) Successful in 1m15s
CI (SIAX Cloud) / quality (push) Successful in 10m1s
CI (SIAX Cloud) / integration (push) Successful in 10m5s
Retry-safe RLS policies (DROP POLICY IF EXISTS) + header fix.
2026-11-07 16:28:21 +00:00
SIAX Agent 0213459f33 fix(sql): 022 canonical facts blir idempotent (DROP POLICY IF EXISTS)
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 48s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m47s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m39s
CREATE POLICY saknade skydd mot omkörning, så en retry efter partiell körning
skulle fastna på 'policy already exists'. Tabellerna använder redan
CREATE TABLE/INDEX IF NOT EXISTS; policynivån görs nu likvärdig. Rubriken
rättad från 020 till 022.

Verifierat på scratch-databas: två körningar i rad OK, RLS ENABLE+FORCE,
3 policies, 11 index, cross-tenant INSERT nekad och tenant B ser 0 rader.
2026-11-07 17:24:56 +01:00
admin c91d2aaca6 Merge pull request #494: 0pt internal-service tenant isolation
masterplan-lock / check (push) Successful in 9s
Security Scan (SIAX Cloud) / security (push) Successful in 32s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 57s
CI (SIAX Cloud) / security (push) Successful in 58s
CI (SIAX Cloud) / sonar (push) Successful in 1m15s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m44s
Deploy to Coolify / deploy (push) Successful in 1m59s
CI (SIAX Cloud) / integration (push) Successful in 9m48s
CI (SIAX Cloud) / quality (push) Successful in 10m22s
Server-configured INTERNAL_SERVICE_TENANTS; forged verified headers stripped; bridge-run guarded; single-tenant rule until workload-identity binding exists.
2026-11-07 15:55:11 +00:00
SIAX Agent 1a581fde29 fix(auth): en (1) tenant per intern serviceprincipal — multi-tenant nekas
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
Den delade X-Internal-Token identifierar EN tjänsteprocess, inte flera
oberoende principaler. En allowlist med flera tenants skulle låta varje
tokeninnehavare välja mellan dem — samma klientstyrda auktoritet som guarden
ska ta bort. Multi-tenant-tjänsteåtkomst kräver verifierad
workload-identity-binding (CL0UD-delegation); tills den finns failar en
ambig konfiguration stängt (401) och ingen identitet forwardas.

Tester: multi-tenant nekas utan side effect, en tenant fungerar, 75/75 i
auth+ cross-tenant-sviterna, tsc ren.
2026-11-07 16:39:48 +01:00
admin bfdc5d1326 Merge pull request 'ci: add blocking component conformance gate' (#496) from ci/design-gate-20261107 into main
Security Scan (SIAX Cloud) / security (push) Successful in 21s
masterplan-lock / check (push) Successful in 1m34s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 1m39s
CI (SIAX Cloud) / security (push) Successful in 1m55s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m8s
CI (SIAX Cloud) / sonar (push) Successful in 2m47s
Deploy to Coolify / deploy (push) Successful in 2m50s
CI (SIAX Cloud) / integration (push) Successful in 11m23s
CI (SIAX Cloud) / quality (push) Successful in 11m48s
2026-11-07 15:32:45 +00:00
admin 831781f19d ci: add blocking component conformance gate
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 30s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m51s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m21s
2026-11-07 15:21:31 +00:00
SIAX Agent b97ad0b2f4 fix(auth): bind internal-service tenant authority to server config
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m24s
The first revision of this fix copied a CLIENT header (X-0pt-Internal-Tenant)
or a single INTERNAL_TENANT_ID into x-0pt-verified-tenant. That is not an
authorization source: any holder of the shared internal token could still name
any tenant, leaving the original cross-tenant write reachable through a
different header.

- INTERNAL_SERVICE_TENANTS (server env) is now the only tenant authority for
  the X-Internal-Token principal; empty => no authority (fail closed, 403).
- Middleware strips inbound x-0pt-verified-{tenant,sub} and the service scope
  header on every path, including the dev escape hatch.
- x-0pt-verified-service-tenants carries the allowlist; the tenant guard checks
  membership and denies anything outside it.
- POST /api/0pt/quality/bridge-run now runs the same tenant guard as
  canonical/facts (it auto-provisions tenant-scoped rules).
- 16 new regression tests; auth + cross-tenant + canonical + bridge suites
  87/87; tsc --noEmit clean.
2026-11-07 15:32:51 +01:00
admin 019cc23fa2 feat(capability): descriptor-completion wave 1 (0pt) — verified openapi operationIds
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m2s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m8s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-07 15:23:41 +01:00
admin 849e7ae802 fix(auth): internal-token requests får verifierad tenant (P0 tenant-isolering)
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 43s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m3s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m15s
Bevisat: POST /api/0pt/canonical/facts med X-Internal-Token och tenant_id
999999999999999999 returnerade 201 och skrev data i annan tenant. Orsak:
internal-token-grenen returnerade next() utan x-0pt-verified-tenant, så
assertTenantMatchesVerified blev no-op.

Fix: INTERNAL_TENANT_ID (eller X-0pt-Internal-Tenant) sätts som verifierad
tenant, vilket gör guard-enforcement identisk med verifierad Bearer. Varnar
om variabeln saknas.
2026-11-07 14:55:59 +01:00
admin 04aeaa754b Merge pull request 'ci(sonar): hosten är sonar.siax.dev, inte .io' (#492) from fix/sonar-host-url into main
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m1s
Deploy to Coolify / deploy (push) Successful in 1m36s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m34s
CI (SIAX Cloud) / quality (push) Successful in 1m37s
CI (SIAX Cloud) / integration (push) Successful in 9m54s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m39s
masterplan-lock / check (push) Successful in 4s
2026-11-06 11:33:13 +00:00
admin 98039d3d68 ci(sonar): self-hosted SonarQube lives at sonar.siax.dev, not .io
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 46s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m30s
2 of 3 recent main runs failed the sonar gate with 'Failed to query server
version: Call to URL https://sonar.siax.io/... failed: null' -- DNS for
sonar.siax.io does not resolve (000) while sonar.siax.dev answers 200 UP
(SonarQube 26.10). Internal tool + admin console = siax.dev per the 2026-11-03
domain policy; the .io hostname was never registered.
2026-11-06 12:17:53 +01:00
admin 8c91b90ae0 Merge pull request 'fix(portal): återanvänd kanonisk @siax/ui FileDropzone' (#490) from feat/ui-reuse-canonical-dropzone into main
CI (SIAX Cloud) / sonar (push) Failing after 8s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / quality (push) Successful in 45s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m8s
Deploy to Coolify / deploy (push) Successful in 1m23s
CI (SIAX Cloud) / integration (push) Successful in 9m46s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 9s
masterplan-lock / check (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m55s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m20s
2026-11-06 10:19:28 +00:00
admin 4f92cdd102 fix(portal): reuse canonic @siax/ui FileDropzone instead of a local ad-hoc one
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 57s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m21s
0pt rendered its own dashed drop surface (role=button + hidden input). @siax/ui
already ships FileDropzone — the native labelled input with drag-over/drop
forwarding, aria wiring and no upload logic — so replace the local variant
(REUSE, CLASSIFICATION per UI standard). Removes the local inputRef.
2026-11-06 11:04:03 +01:00
admin 9ca16b7f6c Merge pull request 'feat(@siax/access): universell idempotenskontrakt' (#489) from feat/universal-idempotency-tuple into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (push) Failing after 7s
CI (SIAX Cloud) / security (push) Successful in 18s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / integration (push) Successful in 27s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m13s
Deploy to Coolify / deploy (push) Successful in 1m28s
CI (SIAX Cloud) / quality (push) Successful in 10m22s
2026-11-06 10:01:59 +00:00
admin a424cf1581 fix(@siax/access): restore relationships.ts that disappeared when the package split into two files
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 13s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 9m59s
b323948 shipped the OpenFGA relationship client inside src/index.ts; when
src/idempotency.ts was added, index.ts was rewritten as the diff of both,
losing every relationship export and breaking the existing tests. Move the
original module to src/relationships.ts and let index.ts re-export both.

Restores 7 pre-existing relationship tests (both suites 14 passed).
2026-11-06 10:49:11 +01:00
admin f77a4fec96 feat(@siax/access): universal idempotency contract
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 3s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / quality (pull_request) Failing after 30s
CI (SIAX Cloud) / integration (pull_request) Canceled after 1m12s
operationIdentity binds tenant/project/actor + every result-affecting input
into a stable fingerprint; a changed payload under the same key is a CONFLICT,
never a replay of someone else's receipt. runIdempotentOperation encodes the
0pt-proven order (durable claim -> downstream dispatch -> save receipt ->
replay) and the commit boundaries. Structural IdempotencyStore lets each
product ship its own Postgres adapter while sharing the contract.

Derived from 0pt's durable upload attempts; DOC0/N0D can adopt the same
shape instead of re-implementing it.
2026-11-06 10:47:59 +01:00
admin 58f81a10b7 Merge pull request 'feat(idempotens): operationsscope på uppladdningsidentitet' (#488) from feat/idempotency-operation-scope into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (push) Failing after 6s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / integration (push) Successful in 27s
CI (SIAX Cloud) / security (push) Successful in 38s
CI (SIAX Cloud) / quality (push) Successful in 41s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m48s
Deploy to Coolify / deploy (push) Successful in 2m0s
2026-11-06 09:44:37 +00:00
admin 6ca6e8e0a6 feat(idempotency): operation-scope binding for upload identity
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 7s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m17s
Per the reap-use mandate: 'identical file + different instruction' is a
DIFFERENT operation. bindOperationToUploadIdentity() folds instruction,
field contract, model, data class and export format into the DOC0-compatible
upload filename so the durable fingerprint covers every result-affecting
input. The bytes' SHA-256 stays stable; DOC0 still receives the caller's
x-idempotency-key (its own idempotency, its own contract).

Classification: STANDARDIZE/0pt-owned (upstream DOC0 untouched).
2026-11-06 10:29:59 +01:00
admin cb0b951c42 Merge pull request 'chore(ui): document domain/legacy UI exceptions' (#486) from feat/component-allowlist-20261105 into main
CI (SIAX Cloud) / sonar (push) Failing after 28s
CI (SIAX Cloud) / security (push) Successful in 39s
Security Scan (SIAX Cloud) / security (push) Successful in 40s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m48s
Deploy to Coolify / deploy (push) Successful in 2m52s
CI (SIAX Cloud) / integration (push) Successful in 10m27s
CI (SIAX Cloud) / quality (push) Successful in 10m50s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
masterplan-lock / check (pull_request) Successful in 38s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m13s
2026-11-05 23:36:47 +00:00
admin 415396f25d chore(ui): document domain/legacy UI exceptions
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m53s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m31s
2026-11-06 00:36:16 +01:00
admin c46ebbf810 feat(0pt): canonical-fact machine route + renumber migration to 022 (#484)
CI (SIAX Cloud) / sonar (push) Failing after 13s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 20s
Deploy to Coolify / deploy (push) Successful in 2m0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m54s
CI (SIAX Cloud) / quality (push) Successful in 10m4s
CI (SIAX Cloud) / integration (push) Successful in 9m49s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m28s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m3s
2026-11-05 19:16:13 +00:00
admin 03cdb7815e feat(0pt): canonical structured observations — entities, facts, relations (#480)
CI (SIAX Cloud) / integration (push) Successful in 10m10s
CI (SIAX Cloud) / quality (push) Successful in 10m10s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m21s
Deploy to Coolify / deploy (push) Successful in 1m27s
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (push) Failing after 10s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / security (push) Successful in 32s
2026-11-05 17:53:34 +00:00
SIAX Agent 116304849b evidence(prod): bind source-bundle artifactId to real deployed image digest
CI (SIAX Cloud) / sonar (push) Failing after 12s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / security (push) Successful in 45s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m27s
Deploy to Coolify / deploy (push) Successful in 1m41s
CI (SIAX Cloud) / integration (push) Successful in 10m6s
CI (SIAX Cloud) / quality (push) Successful in 10m14s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 6s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 38s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m12s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m55s
2026-11-04 12:31:55 +01:00
SIAX Agent c7966eb586 evidence(prod): bind source-bundle artifactId to real deployed image digest
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (push) Failing after 7s
CI (SIAX Cloud) / security (push) Successful in 39s
Security Scan (SIAX Cloud) / security (push) Successful in 1m1s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m44s
Deploy to Coolify / deploy (push) Successful in 1m50s
CI (SIAX Cloud) / quality (push) Successful in 2m12s
CI (SIAX Cloud) / integration (push) Successful in 10m25s
2026-11-04 12:14:30 +01:00
SIAX Agent d5aea59482 evidence(prod): bind source-bundle artifactId to real deployed image digest
masterplan-lock / check (push) Successful in 20s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / sonar (push) Failing after 23s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m1s
Deploy to Coolify / deploy (push) Successful in 2m21s
CI (SIAX Cloud) / quality (push) Canceled after 6m16s
CI (SIAX Cloud) / integration (push) Canceled after 6m16s
2026-11-04 12:08:18 +01:00
SIAX Agent ce6be94e50 evidence(prod): bind source-bundle artifactId to real deployed image digest
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (push) Failing after 6s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / integration (push) Successful in 26s
CI (SIAX Cloud) / quality (push) Successful in 1m55s
Deploy to Coolify / deploy (push) Successful in 3m35s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 3m46s
2026-11-04 12:04:41 +01:00
SIAX Agent 4c20e5457f evidence(prod): real artifact-digest, running-digest and deploy-log for 13e156ec7e
CI (SIAX Cloud) / sonar (push) Failing after 6s
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / integration (push) Successful in 1m0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m5s
CI (SIAX Cloud) / quality (push) Successful in 1m26s
Deploy to Coolify / deploy (push) Successful in 1m57s
2026-11-04 11:53:31 +01:00
siax-estate-bot b29e4848b6 chore(runtime-evidence): sbom — estate-kampanj issue #76 (app 0pt)
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (push) Failing after 12s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 39s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m13s
Deploy to Coolify / deploy (push) Successful in 1m32s
CI (SIAX Cloud) / integration (push) Successful in 9m57s
CI (SIAX Cloud) / quality (push) Successful in 10m33s
2026-11-04 10:32:04 +01:00
admin f53bcf8f50 Merge pull request #477
CI (SIAX Cloud) / security (push) Failing after 7s
CI (SIAX Cloud) / sonar (push) Failing after 14s
Security Scan (SIAX Cloud) / security (push) Failing after 9s
CI (SIAX Cloud) / quality (push) Successful in 1m11s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m21s
Deploy to Coolify / deploy (push) Successful in 1m35s
CI (SIAX Cloud) / integration (push) Successful in 9m52s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 5s
CI (SIAX Cloud) / security (pull_request) Failing after 12s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m17s
CI (SIAX Cloud) / integration (pull_request) Successful in 11m4s
Merged via automated design-conformance rollout after required checks and approval.
2026-11-04 01:45:28 +00:00
SIAX Agent 76e3c0dc2d chore(design): add design-conformance manifest
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Failing after 8s
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 9s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m2s
2026-11-04 02:45:24 +01:00
SIAX Provenance bcdfc536b7 chore(provenance): record the 0pt deployment source bundle
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Failing after 5s
CI (SIAX Cloud) / sonar (push) Failing after 9s
masterplan-lock / check (pull_request) Successful in 13s
CI (SIAX Cloud) / security (push) Failing after 13s
Security Scan (SIAX Cloud) / security (push) Failing after 12s
masterplan-lock / check (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 22s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m33s
Deploy to Coolify / deploy (push) Successful in 1m47s
CI (SIAX Cloud) / quality (push) Successful in 2m3s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m44s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m1s
CI (SIAX Cloud) / integration (push) Successful in 10m0s
Coolify application 0pt finished deployment of commit 13e156ec7e at 2026-11-04T01:26:48Z. This is the source-to-production evidence PROV-SOURCE-BUNDLE requires.
2026-11-04 02:34:08 +01:00
admin 77ddc0654d Merge pull request 'feat(auth): adopt @siax/auth-zitadel canonical claim normalizer' (#475) from feat/auth-zitadel-20261104 into main
masterplan-lock / check (push) Successful in 6s
Security Scan (SIAX Cloud) / security (push) Failing after 7s
CI (SIAX Cloud) / security (push) Failing after 9s
CI (SIAX Cloud) / sonar (push) Failing after 39s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m20s
Deploy to Coolify / deploy (push) Successful in 2m31s
CI (SIAX Cloud) / integration (push) Successful in 10m8s
CI (SIAX Cloud) / quality (push) Canceled after 10m19s
2026-11-04 01:23:52 +00:00
admin 063206d8b0 feat(auth): adopt @siax/auth-zitadel canonical claim normalizer
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Failing after 4s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 11s
masterplan-lock / check (pull_request) Successful in 38s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m5s
CI (SIAX Cloud) / quality (pull_request) Successful in 9m58s
2026-11-04 02:23:21 +01:00
admin 024f4fa8c3 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#473) from fix/prod-restore-2026-09-29 into main
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / integration (push) Successful in 50s
CI (SIAX Cloud) / sonar (push) Successful in 1m0s
CI (SIAX Cloud) / quality (push) Successful in 1m50s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m13s
Deploy to Coolify / deploy (push) Successful in 2m15s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 4s
CI (SIAX Cloud) / security (pull_request) Failing after 6s
masterplan-lock / check (push) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 39s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
2026-11-03 19:31:38 +00:00
admin 19df2b6a20 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 3s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
masterplan-lock / check (push) Successful in 29s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 50s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
2026-11-03 19:31:27 +00:00
admin 13e156ec7e Merge pull request 'feat(ui): upgrade to DES1GN @siax/ui 0.16.13' (#472) from feat/des1gn-ui-upgrade into main
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 13s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / integration (push) Successful in 46s
CI (SIAX Cloud) / quality (push) Successful in 1m0s
CI (SIAX Cloud) / sonar (push) Successful in 1m12s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Failing after 2m58s
Deploy to Coolify / deploy (push) Failing after 11m42s
2026-11-03 19:08:07 +00:00
admin 57616d84b7 feat(ui): upgrade to DES1GN @siax/ui 0.16.13
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m19s
2026-11-03 19:57:16 +01:00
admin b547046963 feat(acquisition): X-800 consumer contract (#470)
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / integration (push) Successful in 1m1s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m8s
Deploy to Coolify / deploy (push) Successful in 1m14s
CI (SIAX Cloud) / sonar (push) Successful in 1m15s
CI (SIAX Cloud) / quality (push) Successful in 10m34s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m11s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
2026-11-03 16:46:24 +00:00
siax-bot 70444a369e fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-ef84832cdb
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 43s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m42s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m54s
2026-11-03 17:30:19 +01:00
admin ef84832cdb Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#469) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / sonar (push) Successful in 1m4s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 59s
CI (SIAX Cloud) / quality (push) Successful in 1m29s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 54s
Deploy to Coolify / deploy (push) Successful in 1m19s
CI (SIAX Cloud) / integration (push) Successful in 9m47s
2026-11-03 15:28:31 +00:00
admin 10b989351a Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / integration (pull_request) Failing after 16s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / quality (pull_request) Successful in 53s
2026-11-03 15:28:19 +00:00
admin 6265734a34 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (8b5e23aa9b)' (#467) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 13s
CI (SIAX Cloud) / sonar (push) Canceled after 13s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Deploy to Coolify / deploy (push) Successful in 4m34s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 4m38s
2026-11-03 15:28:16 +00:00
admin f135ab2ac0 Merge branch 'main' into fix/prod-evidence-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 6s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m0s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
2026-11-03 15:28:03 +00:00
admin cc72e5d434 Merge pull request 'feat(analytics): add 0pt analytics HTTP surface + AUD0 ingestion' (#468) from feat/analytics-api into main
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / quality (push) Successful in 40s
CI (SIAX Cloud) / sonar (push) Successful in 1m27s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m36s
Deploy to Coolify / deploy (push) Successful in 1m44s
CI (SIAX Cloud) / integration (push) Successful in 10m14s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 56s
2026-11-03 13:12:52 +00:00
admin 766db9e9bb fix(analytics): bump PyJWT to 2.14.0 (CVE-2026-102268)
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
masterplan-lock / check (push) Successful in 23s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m11s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m49s
2026-11-03 14:02:39 +01:00
admin 5f5d31f86d feat(analytics): add 0pt analytics HTTP surface + AUD0 ingestion
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Failing after 14s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 58s
CI (SIAX Cloud) / quality (pull_request) Canceled after 1m59s
2026-11-03 14:00:34 +01:00
siax-bot 4d88157cc1 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-8b5e23aa9b
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m27s
2026-11-03 13:30:22 +01:00
admin 8b5e23aa9b Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#466) from fix/prod-restore-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / sonar (push) Successful in 1m5s
Deploy to Coolify / deploy (push) Successful in 3m2s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 3m13s
CI (SIAX Cloud) / quality (push) Successful in 10m0s
CI (SIAX Cloud) / integration (push) Successful in 10m30s
2026-11-03 11:24:46 +00:00
admin 068d00febb Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m7s
2026-11-03 11:24:35 +00:00
admin 9f74b53a4d Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (5772b4043a)' (#465) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 13s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 15s
Deploy to Coolify / deploy (push) Successful in 2m31s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m31s
2026-11-03 11:24:31 +00:00
siax-bot ea16eabf79 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-5772b4043a
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m3s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m25s
2026-11-03 09:30:18 +01:00
admin 5772b4043a Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#464) from fix/prod-restore-2026-09-29 into main
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 34s
CI (SIAX Cloud) / quality (push) Successful in 1m0s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 56s
Deploy to Coolify / deploy (push) Successful in 32s
CI (SIAX Cloud) / sonar (push) Successful in 2m30s
CI (SIAX Cloud) / integration (push) Successful in 9m50s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m2s
2026-11-03 07:28:15 +00:00
admin afa3a888bc Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
masterplan-lock / check (push) Successful in 13s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / integration (pull_request) Successful in 51s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
2026-11-03 07:28:03 +00:00
admin 9c99c2c633 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (b70ae2bbfe)' (#463) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 17s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m22s
Deploy to Coolify / deploy (push) Successful in 1m55s
2026-11-03 07:28:00 +00:00
siax-bot 2ec3fe9b64 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-b70ae2bbfe
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 25s
CI (SIAX Cloud) / security (pull_request) Successful in 38s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m20s
2026-11-03 05:30:19 +01:00
admin b70ae2bbfe Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#462) from fix/prod-restore-2026-09-29 into main
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / sonar (push) Successful in 1m20s
CI (SIAX Cloud) / security (push) Successful in 1m43s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m57s
Deploy to Coolify / deploy (push) Successful in 2m26s
CI (SIAX Cloud) / integration (push) Successful in 10m13s
CI (SIAX Cloud) / quality (push) Successful in 10m41s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 58s
2026-11-03 03:00:45 +00:00
admin c33d3f4d76 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m2s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m33s
2026-11-03 03:00:33 +00:00
admin 03e065c34d Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (140a02a797)' (#461) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 14s
CI (SIAX Cloud) / security (push) Canceled after 13s
CI (SIAX Cloud) / sonar (push) Canceled after 13s
Security Scan (SIAX Cloud) / security (push) Canceled after 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 3m22s
Deploy to Coolify / deploy (push) Successful in 3m25s
2026-11-03 03:00:30 +00:00
siax-bot 35719a6c68 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-140a02a797
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 8s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m38s
2026-11-03 01:30:31 +01:00
admin 140a02a797 Merge pull request 'fix(portal): en idempotensnyckel per vald fil' (#460) from fix/ui-upload-key into main
Security Scan (SIAX Cloud) / security (push) Successful in 34s
CI (SIAX Cloud) / security (push) Successful in 42s
CI (SIAX Cloud) / sonar (push) Successful in 1m13s
CI (SIAX Cloud) / quality (push) Successful in 1m32s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m35s
Deploy to Coolify / deploy (push) Successful in 1m38s
CI (SIAX Cloud) / integration (push) Successful in 10m10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 7s
masterplan-lock / check (pull_request) Successful in 6s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 59s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m57s
2026-11-03 00:04:54 +00:00
admin 446b9aa299 fix(portal): one idempotency key per selected file
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m37s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m40s
A double click or a retry after a network error generated a new key each time
and therefore a duplicate document. The key is now stable until the file
selection changes, so the same upload returns the same verified receipt.
2026-11-03 00:51:42 +01:00
admin 6652fde6df Merge pull request 'fix(upload): idempotent retry ger samma verifierade kvitto' (#459) from fix/upload-retry-receipt into main
masterplan-lock / check (push) Successful in 3s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / integration (push) Successful in 59s
CI (SIAX Cloud) / sonar (push) Successful in 59s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m7s
Deploy to Coolify / deploy (push) Successful in 1m27s
CI (SIAX Cloud) / quality (push) Successful in 10m3s
2026-11-02 23:36:57 +00:00
admin 92eb78ba0a fix(upload): an idempotent retry returns the same verified receipt
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
CI (SIAX Cloud) / integration (pull_request) Successful in 37s
CI (SIAX Cloud) / quality (pull_request) Successful in 43s
A client retry with the same idempotency key replayed the journal and got
'outcome_unknown' even when the first upload had succeeded, because the
verified DOC0 receipt was never persisted. Store document/version/checksum/
stage on the attempt (sql/021) and return it on replay; only a missing receipt
falls back to observation. No second POST, no duplicate document.
2026-11-03 00:35:32 +01:00
admin 96d61d1308 Merge pull request 'fix(credits): importera trial-helpern i balance-routen' (#457) from fix/balance-trial-import into main
CI (SIAX Cloud) / security (push) Successful in 24s
Deploy to Coolify / deploy (push) Successful in 1m35s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m33s
CI (SIAX Cloud) / sonar (push) Successful in 1m41s
Security Scan (SIAX Cloud) / security (push) Successful in 1m54s
CI (SIAX Cloud) / quality (push) Successful in 3m21s
CI (SIAX Cloud) / integration (push) Successful in 9m57s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 37s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m11s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m36s
2026-11-02 21:39:17 +00:00
admin 4ea55c74bb fix(credits): import the trial-credit helper in the balance route
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 6s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 46s
CI (SIAX Cloud) / security (pull_request) Successful in 46s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m47s
The call was added without its import, so GET /api/0pt/credits/balance
returned CREDITS_UNAVAILABLE (readTrialCredits is not defined). Caught live
right after enabling trial credits.
2026-11-02 22:27:19 +01:00
admin ffd7e3341e Merge pull request 'feat(credits): engångs trial-grant så ledgern körs skarpt' (#456) from feat/credit-trial-grant into main
masterplan-lock / check (push) Successful in 4s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / security (push) Successful in 32s
CI (SIAX Cloud) / integration (push) Successful in 49s
CI (SIAX Cloud) / sonar (push) Successful in 1m8s
CI (SIAX Cloud) / quality (push) Successful in 1m13s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m21s
Deploy to Coolify / deploy (push) Successful in 1m38s
2026-11-02 21:17:39 +00:00
admin dade37ee05 feat(credits): one-time trial grant so the ledger runs in production
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m58s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m25s
- Ledger accepts kind='grant' (a server-owned balance grant, not a payment).
- OPT_TRIAL_CREDITS=N grants N credits once per tenant (idempotent key
  trial:<tenant>) on balance/structure, so reserve -> settle is exercised
  before any customer purchase.
- sql/020 extends the kind constraint (re-runnable).
- Tests for the grant helper; full suite 1345 passed.
2026-11-02 22:05:06 +01:00
admin 456e331d80 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (6a4a45a480)' (#455) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m13s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m20s
Deploy to Coolify / deploy (push) Successful in 1m32s
CI (SIAX Cloud) / sonar (push) Successful in 1m50s
CI (SIAX Cloud) / integration (push) Successful in 10m58s
2026-11-02 20:50:29 +00:00
admin 6a4a45a480 Merge pull request 'ci(integration): rensa OpenSearch read-only-block (inte bara vattenmärket)' (#454) from fix/ci-opensearch-clear-readonly into main
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 27s
CI (SIAX Cloud) / quality (push) Successful in 40s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 49s
Deploy to Coolify / deploy (push) Successful in 56s
CI (SIAX Cloud) / sonar (push) Successful in 58s
CI (SIAX Cloud) / integration (push) Successful in 10m5s
2026-11-02 19:41:48 +00:00
admin f29d2876a1 ci(integration): clear the OpenSearch read-only block, not just the watermark
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 3s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 59s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m24s
Disabling cluster.routing.allocation.disk.threshold_enabled does not clear a
cluster already in flood stage: it keeps cluster.blocks.read_only_allow_delete
and every write returns 403 index_create_block_exception (run 389046, still red
after the previous fix). Clear that block explicitly on every attempt, set the
threshold off at boot, and print the probe/settings bodies for future triage.

Verified locally against opensearch:2.11.1: read-only block -> 403, then
PUT {... read_only_allow_delete: null} -> index creation 200.
2026-11-02 20:29:30 +01:00
admin 24f4a407c8 Merge pull request 'fix(live): betalningsspärr i pilot + tydligt OCR-fel' (#453) from fix/live-honesty-and-billing-guard into main
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / security (push) Successful in 26s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m1s
CI (SIAX Cloud) / quality (push) Successful in 1m13s
Deploy to Coolify / deploy (push) Successful in 1m14s
CI (SIAX Cloud) / sonar (push) Successful in 2m17s
CI (SIAX Cloud) / integration (push) Failing after 5m39s
2026-11-02 19:14:57 +00:00
admin a0dd6b7653 fix(live): refuse billing in pilot mode and explain unparsed documents
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 3s
masterplan-lock / check (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m2s
- Checkout returns 503 CREDITS_DISABLED while OPT_CREDITS_MODE=off, so a
  customer can never pay for credits that the pipeline will not consume.
- Packs expose purchases_enabled; the portal hides Buy and says billing is off.
- Structuring an unparsed document (e.g. a scanned PDF with no text layer) now
  answers 422 DOCUMENT_NOT_PARSED instead of a generic INVALID_REQUEST.
2026-11-02 20:14:12 +01:00
admin 8822b2adaa Merge pull request 'ci(integration): åtgärda OpenSearch disk-vattenmärke (index_create_block)' (#451) from fix/ci-opensearch-watermark into main
masterplan-lock / check (push) Successful in 9s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (push) Successful in 33s
CI (SIAX Cloud) / sonar (push) Successful in 2m2s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m26s
Deploy to Coolify / deploy (push) Successful in 2m28s
CI (SIAX Cloud) / integration (push) Successful in 10m1s
CI (SIAX Cloud) / quality (push) Successful in 10m22s
2026-11-02 18:40:50 +00:00
admin 153c9b0cfc ci(integration): disable the CI-only OpenSearch disk watermark and prove index creation
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
The throwaway single-node cluster inherits the runner's disk watermark; on a
busy runner the flood stage turns every new index read-only and the blocking
suite fails with 403 index_create_block_exception (run 388536). Disable the
threshold for this container and create+delete a probe index before the tests.
2026-11-02 19:18:03 +01:00
admin 32182b50dd Merge pull request 'docs(deploy): runbook + workflow-rubriker matchar live-pipelinen' (#450) from docs/deploy-runbook into main
CI (SIAX Cloud) / security (push) Successful in 18s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m9s
CI (SIAX Cloud) / sonar (push) Successful in 1m17s
Deploy to Coolify / deploy (push) Successful in 1m23s
CI (SIAX Cloud) / quality (push) Successful in 1m28s
CI (SIAX Cloud) / integration (push) Failing after 5m23s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m7s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m30s
2026-11-02 17:58:38 +00:00
siax-bot 6e2d7b3068 Merge pull request 'feat(auth): central Keycloak portal-trust' (#449) from fix/keycloak-portal-trust-20261102 into main
masterplan-lock / check (push) Successful in 8s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / security (push) Successful in 43s
CI (SIAX Cloud) / sonar (push) Successful in 1m6s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m17s
Deploy to Coolify / deploy (push) Successful in 1m20s
CI (SIAX Cloud) / integration (push) Failing after 5m18s
CI (SIAX Cloud) / quality (push) Successful in 10m15s
2026-11-02 17:47:25 +00:00
siax-bot 0b50f342a3 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-c386004f2e
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / quality (push) Successful in 1m35s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 28s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
2026-11-02 17:30:19 +01:00
108 changed files with 5673 additions and 564 deletions
+15
View File
@@ -58,6 +58,16 @@ 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
@@ -280,6 +290,11 @@ OPT_CREDIT_PRICE_BOOK=
# 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
+55 -2
View File
@@ -36,6 +36,8 @@ jobs:
- 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
@@ -95,6 +97,9 @@ jobs:
# 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
@@ -132,6 +137,8 @@ jobs:
- 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
@@ -149,6 +156,33 @@ jobs:
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: |
@@ -260,9 +294,9 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube analysis (self-hosted sonar.siax.io)
- name: SonarQube analysis (self-hosted sonar.siax.dev)
env:
SONAR_HOST_URL: https://sonar.siax.io
SONAR_HOST_URL: https://sonar.siax.dev
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
set -euo pipefail
@@ -275,3 +309,22 @@ jobs:
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']))})"
+2
View File
@@ -21,6 +21,8 @@ jobs:
- 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
-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
+12
View File
@@ -0,0 +1,12 @@
# gitleaks-fingerprints för verifierade falska positiver (REPO-15).
# En post per exakt förekomst: <commit>:<fil>:<regel>:<rad>.
# Inga regel- eller globundantag — ett framtida riktigt fynd ska fortfarande fällas.
#
# Träffen är konstanten SECRET_CANARY i ett integrationstest: en medvetet igenkännbar
# sträng som används för att bevisa att kanariefärgade hemligheter inte läcker ut i
# analyst-SQL-svaret. Att maskera den skulle göra testet oförmöget att mäta läckage.
#
# generic-api-key tests/integration/analyst-sql-clickhouse.integration.test.ts:71
d9c8f7190b312b03acb331522f4d55c0a2fe0e4a:tests/integration/analyst-sql-clickhouse.integration.test.ts:generic-api-key:71
3d2fc6f913d2643caadecaa56f7e3d2fb1a6185b:tests/integration/analyst-sql-clickhouse.integration.test.ts:generic-api-key:71
+30
View File
@@ -0,0 +1,30 @@
{
"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
@@ -0,0 +1 @@
{"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."}]}
+42
View File
@@ -83,3 +83,45 @@ pnpm run lint # advisory
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 -->
+42
View File
@@ -65,3 +65,45 @@ A working engine repo already exists. **Corrected this pass — the previous des
## 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 -->
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.acquisition-consumer.schema.json",
"schemaVersion": "siax.acquisition-consumer/v1",
"application": "0pt",
"owner": "0pt",
"tenantScoped": true,
"capabilities": [
"siax.acquisition.plan",
"siax.acquisition.run",
"siax.acquisition.status",
"siax.acquisition.result",
"siax.acquisition.query_existing",
"siax.acquisition.discovery_search",
"siax.acquisition.extract",
"siax.acquisition.evidence"
],
"allowedPurposes": [
"data.acquisition",
"source.registration"
],
"dataCategories": [
"public",
"company",
"product",
"pricing",
"vehicle",
"property",
"document",
"event",
"other"
],
"maxCostSEK": 25,
"reuseExistingFacts": true,
"mustNotOwnEngines": true,
"engines": []
}
+52
View File
@@ -0,0 +1,52 @@
/**
* app/api/0pt/canonical/facts/route.ts
* ====================================
* POST — canonical structured-observation write (entities/facts/relations).
* GET — tenant-scoped read for consumers (products, agents, L0RE projection).
*
* 0pt owns observed facts; L0RE owns epistemic propositions/projections and
* consumes this API. Tenant is always cross-checked against the verified
* principal (assertTenantMatchesVerified), never trusted from the body alone.
*/
import { NextRequest, NextResponse } from "next/server";
import { canonicalWriteSchema, parseBody } from "@/src/validation/0pt-schemas";
import { canonicalFactService } from "@/src/services/canonical-fact-registry";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
const parsed = parseBody(canonicalWriteSchema, await request.json());
if (!parsed.ok) return NextResponse.json(parsed.body, { status: parsed.status });
const body = parsed.data;
const guard = assertTenantMatchesVerified(request, body.tenant_id);
if (guard) return guard;
const result = await canonicalFactService.write(body);
return NextResponse.json(result, { status: 201 });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tenantId = searchParams.get("tenant_id");
if (!tenantId) return NextResponse.json({ error: "tenant_id is required" }, { status: 400 });
const guard = assertTenantMatchesVerified(request, tenantId);
if (guard) return guard;
const limitRaw = searchParams.get("limit");
const facts = await canonicalFactService.query({
tenant_id: tenantId,
entity_id: searchParams.get("entity_id") ?? undefined,
entity_type: searchParams.get("entity_type") ?? undefined,
identity_namespace: searchParams.get("identity_namespace") ?? undefined,
identity_value: searchParams.get("identity_value") ?? undefined,
predicate: searchParams.get("predicate") ?? undefined,
limit: limitRaw ? Number(limitRaw) : undefined,
});
return NextResponse.json({ facts });
} catch (error: unknown) {
return siaxErrorResponse(error);
}
}
+3
View File
@@ -1,5 +1,6 @@
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';
@@ -24,6 +25,8 @@ export async function GET(request: NextRequest) {
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) {
+9
View File
@@ -27,6 +27,15 @@ export async function POST(request: NextRequest) {
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(
+1
View File
@@ -27,6 +27,7 @@ export async function GET(request: NextRequest) {
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,
+12 -1
View File
@@ -3,6 +3,7 @@ import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-wor
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';
@@ -163,13 +164,22 @@ export async function POST(request: NextRequest) {
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) ||
pipelineStage !== 'parsed' ||
typeof instruction !== 'string' ||
!instruction.trim() ||
instruction.length > 4000 ||
@@ -270,6 +280,7 @@ export async function POST(request: NextRequest) {
creditsEnabled && databaseUrl ? new PostgresCreditLedger(getRepositorySql(databaseUrl)) : undefined;
if (ledger) {
try {
await ensureTrialCredits(ledger, tenantId, readTrialCredits());
await ledger.apply({
tenantId,
idempotencyKey: `reserve:${reservationId}`,
+6
View File
@@ -26,6 +26,7 @@ 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 {
@@ -35,6 +36,11 @@ export async function POST(request: NextRequest) {
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,
+17 -26
View File
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FileDropzone } from '@siax/ui/components/file-dropzone';
import { useT } from '@/components/locale-provider';
import { PortalShell } from '@/components/portal-shell';
@@ -72,7 +73,10 @@ export default function DocumentsPage() {
const [uploadNote, setUploadNote] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [unknownOutcome, setUnknownOutcome] = useState<unknown | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// One idempotency key per selected file: a retry (double click, network
// error, reload-and-pick-the-same-file) returns the SAME document instead of
// creating a duplicate. Picking a different file resets it.
const uploadKey = useRef<string | null>(null);
const [instruction, setInstruction] = useState('');
const [fields, setFields] = useState<FieldRow[]>([{ name: 'organization', type: 'string' }]);
@@ -119,7 +123,9 @@ export default function DocumentsPage() {
const form = new FormData();
form.set('file', file);
form.set('project_id', projectId.trim());
form.set('idempotency_key', crypto.randomUUID());
const key = uploadKey.current ?? crypto.randomUUID();
uploadKey.current = key;
form.set('idempotency_key', key);
form.set('classification', 'restricted');
try {
const response = await fetch('/api/0pt/documents/upload', {
@@ -271,31 +277,16 @@ export default function DocumentsPage() {
/>
</label>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
inputRef.current?.click();
}
<FileDropzone
className="mt-4"
label={t({ en: 'choose a file', sv: 'välj en fil' })}
hint={file ? file.name : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
disabled={uploading}
onFiles={(files) => {
uploadKey.current = null;
setFile(files[0] ?? null);
}}
className="mt-4 flex cursor-pointer flex-col items-center justify-center border border-dashed border-border px-6 py-10 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={label}>{t({ en: 'choose a file', sv: 'välj en fil' })}</span>
<span className="mt-3 text-[15px]">
{file
? file.name
: t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })}
</span>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</div>
/>
<button
type="button"
+28 -9
View File
@@ -12,6 +12,7 @@ export default function CreditsPage() {
const [balance, setBalance] = useState<{ available: number; reserved: number } | null>(null);
const [balanceNote, setBalanceNote] = useState<string | null>(null);
const [packs, setPacks] = useState<Pack[]>([]);
const [purchasesEnabled, setPurchasesEnabled] = useState(true);
const [currency, setCurrency] = useState('SEK');
const [packsNote, setPacksNote] = useState<string | null>(null);
const [buying, setBuying] = useState<string | null>(null);
@@ -31,10 +32,16 @@ export default function CreditsPage() {
}
try {
const r = await fetch('/api/0pt/credits/packs', { cache: 'no-store' });
const body = (await r.json()) as { packs?: Pack[]; currency?: string; message?: string };
const body = (await r.json()) as {
packs?: Pack[];
currency?: string;
purchases_enabled?: boolean;
message?: string;
};
if (r.ok && body.packs) {
setPacks(body.packs);
setCurrency(body.currency ?? 'SEK');
setPurchasesEnabled(body.purchases_enabled !== false);
} else {
setPacksNote(
body.message ??
@@ -127,18 +134,30 @@ export default function CreditsPage() {
{formatSEK(pack.amount_minor / 100)}
</p>
</div>
<button
type="button"
onClick={() => buy(pack.id)}
disabled={buying !== null}
className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
>
{buying === pack.id ? t({ en: 'opening…', sv: 'öppnar…' }) : t({ en: 'buy', sv: 'köp' })}
</button>
{purchasesEnabled ? (
<button
type="button"
onClick={() => buy(pack.id)}
disabled={buying !== null}
className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.18em] disabled:opacity-40"
>
{buying === pack.id
? t({ en: 'opening…', sv: 'öppnar…' })
: t({ en: 'buy', sv: 'köp' })}
</button>
) : null}
</li>
))}
</ul>
)}
{!purchasesEnabled ? (
<p className="mt-4 text-[14px] text-muted-foreground">
{t({
en: 'Billing is not active yet (pilot). No payment can be taken.',
sv: 'Betalning är inte aktiverad än (pilot). Ingen betalning kan tas.',
})}
</p>
) : null}
{status ? <p className="mt-4 text-[14px] text-muted-foreground">{status}</p> : null}
<p className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{currency}
+277
View File
@@ -0,0 +1,277 @@
# 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
@@ -0,0 +1,36 @@
# SCAFFOLD-TODO — vad som är härlett och vad som måste verifieras
**Genererad:** 2026-11-10 av `siax-scaffold` (siax-standard,
`packages/scaffold`) mot Golden Standard v2.0.
Den här filen finns för att en mall inte får låtsas veta saker. Varje rad nedan
är antingen **härledd ur repot** (källan står i kolumn 3) eller ett
**konservativt defaultvärde** som en människa måste bekräfta. Radera raden när
den är verifierad — filen ska till slut kunna tas bort helt.
## Härledda värden i `siax.repo.json`
| Fält | Värde | Härlett ur |
|---|---|---|
| `repo` | `0PT` | befintlig `siax.repo.json` (VERSALER per REPO-08) |
| `codeowners` | `["@sax3l"]` | befintlig `siax.repo.json` |
| `category` | `platform` | befintlig `siax.repo.json` |
| `subcategory` | `data-os` | befintlig `siax.repo.json` |
| `operationalState` | `running` | befintlig `siax.repo.json` |
| `domains` | `2 st` | befintlig `siax.repo.json` |
| `children` | `4 st` | befintlig `siax.repo.json` |
| `packageName` | `@siax/0pt` | befintlig `siax.repo.json` |
## Måste verifieras av människa
- [ ] Rätta den BEFINTLIGA `siax.repo.json` — den validerar inte mot schemat (REPO-03/04) och scaffolden skriver inte över den (AR-1): (roten): obligatoriskt fält saknas: tier; (roten): obligatoriskt fält saknas: runtime; (roten): obligatoriskt fält saknas: canonical; (roten): obligatoriskt fält saknas: parentRepo; (roten): obligatoriskt fält saknas: capabilities; (roten): obligatoriskt fält saknas: surfaces; (roten): obligatoriskt fält saknas: identity; (roten): obligatoriskt fält saknas: persistence; (roten): obligatoriskt fält saknas: deployment; (roten): obligatoriskt fält saknas: observability; (roten): obligatoriskt fält saknas: reliability; (roten): obligatoriskt fält saknas: engines; (roten): obligatoriskt fält saknas: licenses; (roten): obligatoriskt fält saknas: productionEvidence; /standardVersion: måste vara "3.0", är "2.0"; (roten): okänt fält "codeowners" (additionalProperties: false); (roten): obligatoriskt fält saknas: canonical
## Kontroller som scaffolden AVSIKTLIGT lämnar röda
Inga — de tre fall den här sektionen annars varnar för (`appId` satt till `null`, ett genererat kontraktsskelett, ett nyskrivet `backup-restore.md` utan datum) gäller inte i denna körning. Se "Måste verifieras" ovan för vad som faktiskt återstår.
---
_Genererad av `siax-scaffold` (siax-standard). Varje `TODO:` i filen måste
fyllas i av en människa. Samlad lista: `docs/compliance/SCAFFOLD-TODO.md`._
+5 -1
View File
@@ -37,7 +37,11 @@ is accepted by middleware as a verified principal (Bearer still wins).
releasing it.
6. **No price is ever read from the client.** The price book is a versioned,
server-owned JSON document (`OPT_CREDIT_PRICE_BOOK`).
7. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and
7. **Trial credits are a grant, not a payment.** `OPT_TRIAL_CREDITS=N` grants N
credits once per tenant (`kind='grant'`, idempotency key `trial:<tenant>`),
so the ledger is fully exercised before any real purchase. It is never read
from a client and never looks like a Stripe payment.
8. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and
reports `credits: null`. It is not a silent fallback and it does not enable
purchases; unset/`on` requires the price book and the ledger.
+92
View File
@@ -0,0 +1,92 @@
# 0pt — produktionsberedskap
> Standardiserad gap-analys enligt SIAX Master Definition of Done.
> Dokumentet är evidens, inte ett leveransgodkännande. Endast `PRODUCTION_READY` får kallas klart.
## Identitet
- Repo: `https://git.siax.io/admin/0pt.git`
- Manifest: `siax.repo.v3.json` — `0PT`, operationalState `running`
- Default branch: `main`
- Bedömd nivå: **LIVE_VERIFIED** (manifestets DoD-anspråk: ja)
- UI: ja — app, public
- CI-arbetsflöden: 9
## Git- och arbetsyteläge
- Aktuell branch: `chore/surface-posts-20261110` — ahead 1 / behind 0
- Ocommittade filer: 0 · otrackade: 0
- Worktrees: 1
- Lokala grenar: 2 · remote: 19
- Omergeade lokala grenar:
- `chore/surface-posts-20261110` (+1 commits)
## Livebevis (observerat 2026-11-09T23:48:50)
| Värd | HTTP | Klass | Titel | SIAX-tokens |
|---|---|---|---|---|
| `0pt.app` | 200 | public-ok | 0pt — your data | nej |
| `api.0pt.app` | 200 | public-ok | 0pt — your data | nej |
401/403 är förväntat för autentiserade ytor; det är inte ett produktionsfel.
0 betyder DNS/TLS/anslutning misslyckades och är alltid ett gap. SIAX-tokens avser markörer i serverad HTML; ett SPA-bygge kan bära dem i CSS/JS och då är kolumnen indikativ, inte bevis.
## Kod- och UI-signaler
- Trackade filer: 954
- Testfiler: 178 · E2E: 0 · Playwright: False · Cypress: False
- API-kontrakt: contracts/openapi.yaml
- Migrationer: 0 · Health-referenser: 69
- `@siax/ui`/tokens: True · a11y-verktyg: False · design-conformance: True
- UI-tillståndsfiler: app/error.tsx, app/not-found.tsx
- TODO/FIXME: 10
- Exempel:
- `.gitea/CODEOWNERS:10:# TODO: lägg till migrationskatalogen när den finns, t.ex.`
- `.gitea/workflows/cd.yml:15:# The placeholder guard below remains as a fail-closed guard, not as a TODO.`
- `.gitea/workflows/cd.yml:34: COOLIFY_APP_ID: ${{ vars.COOLIFY_APP_ID || 'TODO-no-coolify-app-provisioned-yet' }}`
- `.gitea/workflows/cd.yml:91: if [ "$COOLIFY_APP_ID" = "TODO-no-coolify-app-provisioned-yet" ]; then`
- `README.md:85: `TODO-no-coolify-app-provisioned-yet` literal. Both guard conditions in`
## Gap mot PRODUCTION_READY
- `NO_BACKUP_POLICY`
- `UNMERGED_LOCAL_BRANCHES`
- `UNPUSHED_COMMITS`
## Prioriterad backlogg
| Prio | Åtgärd | Konkret nästa steg |
|---|---|---|
| P0 | Landa grenar | Kör PR-flöde per gren, verifiera CI och merga; radera grenen lokalt och på origin efter merge. |
| P1 | Backup | Definiera frekvens, retention, mål och kryptering i `siax.backup`. |
| P1 | E2E i webbläsare | Lägg Playwright-flöde för inloggning och kärnflöde mot riktig miljö. |
| P1 | Tillgänglighetsgrind | Aktivera eslint-plugin-jsx-a11y/axe och en blockerande WCAG 2.2-kontroll. |
| P2 | Statusdokument | Lägg `docs/STATUS.md` med nivå, bevis, kända gap och nästa steg. |
| P2 | Teknisk skuld | Prioritera bland 10 TODO/FIXME-markeringar; stäng eller gör dem spårbara som issues. |
## Checklista före PRODUCTION_READY
- [ ] Canonical owner och produktgräns dokumenterad
- [ ] Versionerat API-/eventkontrakt med drift-check
- [ ] Tenant-isolering verifierad med negativt test
- [ ] Observability: metrics, loggar, spårning och larm
- [ ] Backup och verkligt återställningstest
- [ ] Live-E2E med verklig konsument, inte mock
- [ ] Deployad commit-SHA och build-ID utlästa ur runtime
- [ ] UI/UX granskad mot DES1GN och WCAG 2.2 där UI finns
## Verifieringskommandon
```bash
mise run check && mise run verify && mise run build
siax-doctor design --repo . --fail-on=block
curl -fsS https://<prod-host>/health && curl -fsS https://<prod-host>/ready
```
## Standardreferenser
- `siax-standard/standard/MASTER-DEFINITION-OF-DONE.md`
- `siax-standard/standard/SIAX-REPO-OPERATIONS-CONTRACT.md`
- `siax-standard/standard/registry/REUSE_FIRST_POLICY.md`
_Genererad 2026-11-09T23:41:39 från git, manifest, kodsignaler och live-prober. Omgenereras av `scripts/estate-production-audit.py`._
+2
View File
@@ -103,3 +103,5 @@ psql -d opt < backup # eller: pg_restore -d opt opt-YYYY-MM-DD.dump
1. Provisionera tillfällig Postgres (pgvector:pg16).
2. `pg_restore` senaste dump. 3. Sätt `DATABASE_URL` i dev-instans.
4. Verifiera orderflöde + en källfråga. 5. Logga resultatet i `docs/compliance/EVIDENCE/`.
**Senast bevisad restore: 2026-09-03** — P014-restoreövningen i docs/runbook/backup-restore.md avsnitt P014 (exekverad 2026-09-03): verklig backup -> isolerad engångstarget (pgvector/pgvector:pg16) -> restore, körd mot faktisk infrastruktur, inga persistenta artefakter kvarlämnade.
+47
View File
@@ -0,0 +1,47 @@
# Legacy instance audit — 0pt (server5) and L0RE (server5) — 2026-11-07
Canonical runtimes: **0pt on server6** (Coolify app `shxd6lepxuxfsjsp0ath3z1g`,
`0pt.app`) and **L0RE on server9** (`l0re.siax.io`). This document records the
older instances on server5 and their disposition. It is not production
documentation for either product.
## 1. 0pt legacy (server5)
| Item | Value |
|---|---|
| Containers | `0pt-api` (`0pt-api:c46ebbf`, created 2026-11-05), `0pt-web` (`:18080`) |
| Database | `0pt-pg`, user/db `opt`, 11 MB |
| Exposure | `0pt-web` only on `100.124.7.55:18080`; no DNS record, no reverse proxy |
| Consumers | none found (`/etc/siax`, `/opt/siax`, site0 and L0RE deploy configs) |
| Unique data | 32 canonical facts (30 GLEIF web observations + `body_type` YDL405 + `passmark.find_prime_numbers.v10`) |
**Data reconciliation:** all 32 facts were exported and re-imported through the
canonical API (`POST /api/0pt/canonical/facts`) into the server6 deployment.
Replay returned `duplicates: 32`, so the import is idempotent. Production now
holds 34 facts (32 migrated + 2 verification observations) and 4 entities.
**Backup:** `server5:/var/backups/0pt-legacy/opt_legacy_20261107.dump.zst`
(custom format, 32 KB) + offsite `server11:/home/backup/0pt/`.
**Action taken:** `docker update --restart=no` + `docker stop` for `0pt-api` and
`0pt-web`. `0pt-pg` left running for forensics/rollback. `0pt.app` verified 200
after the stop (served by server6).
**Full removal (requires owner decision):** snapshot/destroy `0pt-pg` volume
after the retention window, remove the legacy compose project, revoke the old
`INTERNAL_TOKEN` if it was ever stored on server5.
## 2. L0RE orphan (server5)
Documented separately in `admin/l0re` PR #485
(`docs/runbook/l0re-orphan-decommission-20261107.md`): API stopped with
`restart=no`, dump + restore proof (8 propositions/62 assertions), no unique
legitimate data (pilot facts already re-projected to production). `l0re-pg` and
`l0re-opensearch` remain for forensics.
## 3. Single-runtime rule
One primary runtime per logical service. A container image of a product running
outside its registered Coolify app is a finding, not a fallback. Both server5
instances were reachable only from the host, which is why the split survived
until this audit.
+78
View File
@@ -0,0 +1,78 @@
# 0pt workload identity — målbild, nuläge och övergång
Status: **design klar, provisionering blockerad** (2026-11-07).
Ägare: CL0UD (identitet) / 0pt (konsument).
## 1. Nuläge
0pt autentiserar maskin-till-maskin med en **delad hemlighet**:
- `X-Internal-Token` mot `INTERNAL_TOKEN` (samma värde i engine och edge).
- Tenant-auktoritet kommer från serverkonfigurationen `INTERNAL_SERVICE_TENANTS`
(se `src/auth/internal-service-tenant.ts`), aldrig från en klientheader.
- Tom allowlist ⇒ ingen tenant-auktoritet ⇒ tenant-scopade skrivningar 403.
Det här är säkert men grovkornigt: en enda hemlighet representerar hela
engine-processen, och varje ny konsument kräver en manuell allowlist-post.
## 2. Målbild (CL0UD workload identity)
CL0UD äger redan maskineriet:
- `POST /account/service-accounts` skapar en service account (identitet).
- `POST /account/service-accounts/{id}/workload-token` mints en **kortlivad**
(10 min default, 1 h max) workload-token vars `capabilities` är en delmängd av
service accountens `platform.cl0ud_service_account_scopes.allowedCapabilities`.
- `@siax/platform-core` `auth/workload.ts` är verifieringsadaptern; publika
nycklar via JWKS.
- `packages/capability-registry/src/workload-identities.ts` deklarerar
arbetsbelastningsnamn och `aud:*`-audiences. Modulen är uttryckligen **inte**
provisionerad och **inte** inkopplad i utfärdande eller verifiering än.
För 0pt innebär det:
| Egenskap | Idag | Mål |
|---|---|---|
| Credential | delad `INTERNAL_TOKEN` | CL0UD-workload-token, `aud:0pt`, 10 min |
| Tenant | serverkonfigurerad allowlist | `tenant_id`-claim i verifierad token |
| Capabilities | route-lista i middleware | `capabilities`-claim (t.ex. `0pt.canonical.write`) |
| Rotation | manuell | automatisk (kortlivad token) |
| Spårbarhet | delad hemlighet | per-arbetsbelastning (`0pt-engine`, `site0-pipeline`) |
## 3. Integrationsdesign (när provisionering är möjlig)
1. **Verifiering före route-logik.** Middleware accepterar
`Authorization: Bearer <workload-token>` för `INTERNAL_SERVICE_ROUTES` om
signaturen verifieras mot CL0UD JWKS, `aud` innehåller `0pt` och `exp` är
giltig. Ingen klientheader får bidra till tenant.
2. **Tenant från claim, inte från allowlist.** `tenant_id` i den verifierade
tokenen sätts som `x-0pt-verified-tenant`; `INTERNAL_SERVICE_TENANTS`
används endast för legacy `X-Internal-Token`.
3. **Capabilities per route.** Route-metadata (`0pt.canonical.write`,
`0pt.quality.run`) jämförs med tokenens `capabilities`. Saknad capability ⇒
403, inte 401 (identiteten är giltig, behörigheten räcker inte).
4. **Dual-accept under övergång.** Båda credentialtyperna accepteras tills
engine och site0-pipeline mintar workload-tokens; därefter tas
`X-Internal-Token` bort i en egen release med negativt test.
5. **Negativa tester i samma svit som idag**
(`tests/auth/internal-token-tenant-scope.test.ts`): fel `aud` ⇒ 401, utgången
token ⇒ 401, tenant utanför claim ⇒ 403, saknad capability ⇒ 403.
## 4. Blockerare
- `/account/service-accounts` svarar **307 → /login** utan operator-session;
ingen giltig `SIAX_TOKEN` finns i Infisical-miljön.
- Zitadel admin-PAT (`/opt/siax-access/zitadel-admin-pat`) är invalid
(AUTH-7fs1e), så nya machine users kan inte mintas via det flödet.
- `workload-identities.ts` är deklaration-only: ingen identitet är
`provisioned: true`.
Åtgärd kräver en operatörsinloggning i CL0UD-konsolen (eller en ny giltig
admin-PAT) och är därför **EXTERNALLY_BLOCKED** från agentsidan.
## 5. Vad som inte ska göras
- Ingen egen JWT-signering i 0pt.
- Ingen parallell identitetsplattform.
- Ingen klientheader som bär tenant.
- Ingen långlivad delad hemlighet som "tillfällig" lösning utan slutdatum.
+105 -39
View File
@@ -1,58 +1,130 @@
/**
* Next.js auth seam for the complete /api/0pt capability surface.
*
* Customer calls use verified Zitadel bearer identity and receive immutable
* x-0pt-verified-* headers. Explicit machine routes may instead use the
* shared X-Internal-Token convention; mutation handlers verify it again for
* defence in depth.
* Next.js auth seam for /api/0pt. Browser/user calls use verified bearer/session.
* Explicit machine routes accept: (1) legacy X-Internal-Token + server-configured
* single-tenant scope, or (2) verified CL0UD workload JWT (tenant/capabilities
* from claims, never client headers). Deny by default.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
import {
SERVICE_CAPABILITIES_HEADER,
SERVICE_TENANT_SCOPE_HEADER,
resolveInternalServiceTenants,
} from '@/src/auth/internal-service-tenant';
import { isCl0udWorkloadConfigured, looksLikeCl0udWorkloadToken, verifyCl0udWorkloadToken } from '@/src/auth/cl0ud-workload';
assertAuthConfigSafe();
export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'] };
export const config = {
matcher: ['/api/0pt', '/api/0pt/:path*'],
};
const EXEMPT_PATTERNS: RegExp[] = [
/^\/api\/health(\/|$)/,
/^\/api\/ready(\/|$)/,
/^\/api\/admin\/metrics(\/|$)/,
];
/** Routes that may be reached with a service token instead of end-user bearer auth. */
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
/^\/api\/0pt\/quality\/bridge-run$/,
/^\/api\/0pt\/canonical\/facts$/,
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
/^\/api\/0pt\/internal\/er\/candidates$/,
/^\/api\/0pt\/search\/definitions$/,
];
let _authDiagCount = 0;
let internalScopeWarned = false;
/**
* Headers that only middleware may set. Anything a client sends under these
* names is a forgery attempt and is removed before forwarding — including on
* the internal-token path, which previously passed the original header map
* through unchanged.
*/
const VERIFIED_HEADERS = [
'x-0pt-verified-tenant',
'x-0pt-verified-sub',
SERVICE_TENANT_SCOPE_HEADER,
SERVICE_CAPABILITIES_HEADER,
] as const;
function stripVerifiedHeaders(req: NextRequest): Headers {
const forwarded = new Headers(req.headers);
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
return forwarded;
}
function bearerToken(req: NextRequest): string | null {
const h = req.headers.get('authorization');
if (!h?.toLowerCase().startsWith('bearer ')) return null;
return h.slice(7).trim() || null;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
return NextResponse.next();
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path))) {
// (1) Legacy shared internal token (engine/n8n). Tenant authority is SERVER
// CONFIG (INTERNAL_SERVICE_TENANTS), never a client header: an inbound
// `X-0pt-Internal-Tenant` is ignored and stripped. With no configured
// tenants the principal has no tenant authority and tenant-scoped writes
// fail closed with 403.
if (verifyInternalServiceToken(req.headers)) {
const forwarded = stripVerifiedHeaders(req);
const tenants = resolveInternalServiceTenants();
if (tenants.length > 1) {
// The shared X-Internal-Token identifies ONE service process, not a set
// of independently authenticated principals. A multi-tenant allowlist
// would let any holder of that shared token choose between all listed
// tenants, which is exactly the client-controlled authority this guard
// exists to remove. Multi-tenant service access requires a verified
// workload-identity binding (CL0UD delegation contract); until that
// exists, an ambiguous configuration fails closed.
console.error(
'[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists',
);
return NextResponse.json(
{
error: 'OPT_UNAUTHENTICATED',
message:
'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists',
},
{ status: 401 },
);
}
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
if (tenants.length === 1) {
forwarded.set('x-0pt-verified-tenant', tenants[0]);
} else if (tenants.length === 0 && !internalScopeWarned) {
internalScopeWarned = true;
console.warn(
'[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied',
);
}
return NextResponse.next({ request: { headers: forwarded } });
}
// (2) CL0UD workload JWT (dual-accept): tenant and capabilities come from
// verified claims only. Attempted only when the issuer is configured or the
// presented token is issued by it; an invalid one is a 401, never a
// fall-through to an unauthenticated dev path.
const bt = bearerToken(req);
if (bt && (isCl0udWorkloadConfigured() || looksLikeCl0udWorkloadToken(bt))) {
try {
const principal = await verifyCl0udWorkloadToken(bt);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
forwarded.set(SERVICE_CAPABILITIES_HEADER, principal.capabilities.join(','));
return NextResponse.next({ request: { headers: forwarded } });
} catch (err) {
const message = err instanceof Error ? err.message : 'workload token verification failed';
return NextResponse.json({ error: 'OPT_UNAUTHENTICATED', message }, { status: 401 });
}
}
}
if (_authDiagCount < 5) {
_authDiagCount += 1;
const hasAuth = !!req.headers.get('authorization');
console.log(
`[0pt-auth] middleware path=${path} auth_header=${hasAuth} ` +
`auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` +
`node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`,
);
console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`);
}
// Browser sessions (Zitadel OIDC -> signed cookie) are a first-class
// principal for the portal. A Bearer token always wins when present.
if (!req.headers.get('authorization')) {
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
if (
@@ -62,7 +134,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
typeof session.exp === 'number' &&
session.exp > Date.now()
) {
const forwarded = new Headers(req.headers);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', session.sub);
forwarded.set('x-0pt-verified-tenant', session.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
@@ -71,23 +143,17 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
const decision = await resolveAuthContext(req.headers);
if (!decision.ok) {
return NextResponse.json(
{
error: decision.code,
message: decision.message,
...(decision.details ? { details: decision.details } : {}),
},
{ status: decision.status },
);
return NextResponse.json({ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) }, { status: decision.status });
}
const principal = decision.ctx.principal;
if (principal) {
const forwarded = new Headers(req.headers);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
return NextResponse.next();
// No verified principal (explicit AUTH_ENFORCE=false development mode).
// Never forward a client-named verified header, even in dev.
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
}
+5 -4
View File
@@ -52,13 +52,15 @@
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@siax/0pt-decision": "workspace:*",
"@siax/access": "workspace:*",
"@siax/auth-zitadel": "0.1.1",
"@siax/charts": "^0.5.0",
"@siax/collaboration": "0.3.0",
"@siax/config": "^0.2.1",
"@siax/contracts": "^0.11.1",
"@siax/contracts": "^0.12.1",
"@siax/import-export": "^0.2.2",
"@siax/tokens": "^0.1.2",
"@siax/ui": "^0.15.1",
"@siax/ui": "^0.16.13",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -81,8 +83,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "3.25.76",
"@siax/access": "workspace:*"
"zod": "3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
+121
View File
@@ -0,0 +1,121 @@
/**
* @siax/access idempotency primitives — the universal SIAX contract for any
* operation that must either happen exactly once or replay its receipt.
*
* This package is derived from the pattern proven in 0pt's durable upload
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
* generalized so DOC0, N0D and future products can adopt the identical shape
* instead of re-implementing it.
*
* Rules encoded here:
* 1. An operation identity binds EVERY result-affecting input (tenant,
* project, actor, payload version). A changed payload under the same key
* is a CONFLICT, never a replay of someone else's receipt.
* 2. One claimant per key: returning `false` from `start` means another
* caller owns the dispatch, and only the stored receipt (or bounded
* observation) may be answered.
* 3. A stored receipt is replayed verbatim; the caller never invents one.
* 4. Commit boundaries: NEVER write a network command before the claim is
* durably persisted, and never lose the claim after the command may have
* committed.
*/
import { createHash } from "node:crypto";
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
return createHash("sha256")
.update(JSON.stringify([version, ...parts]))
.digest("hex");
}
export interface OperationIdentity {
tenantId: string;
projectId: string;
actorId: string;
/** Client-generated, transport-level key (<=256 visible ASCII). */
idempotencyKey: string;
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
payload: readonly unknown[];
}
export function operationIdentity(operation: OperationIdentity): {
tenantId: string;
projectId: string;
actorId: string;
idempotencyKey: string;
fingerprint: string;
} {
if (
!operation ||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
) ||
!Array.isArray(operation.payload)
) {
throw new Error("Invalid idempotent operation identity");
}
void operation.payload;
return {
tenantId: operation.tenantId,
projectId: operation.projectId,
actorId: operation.actorId,
idempotencyKey: operation.idempotencyKey,
fingerprint: operationFingerprint([
operation.tenantId,
operation.projectId,
operation.actorId,
...operation.payload,
]),
};
}
export type OperationOutcome<R> =
| { kind: "claimed"; execute: () => Promise<R> }
| { kind: "replayed"; receipt: R }
| { kind: "conflict"; expected?: string };
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
export interface IdempotencyStore<Identity, Receipt> {
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
start(identity: Identity, fingerprint: string): Promise<boolean>;
/** Persist after a verified downstream commit. Must never be called before. */
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
/** Load a stored receipt for replay (exact fingerprint match). */
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
}
export type IdempotentStoreItemIdentity<I> = {
identity: I;
fingerprint: string;
};
export async function runIdempotentOperation<I, R>(input: {
store: IdempotencyStore<I, R>;
identity: I;
fingerprint: string;
/** Must be safe to call exactly once; a second call is never attempted here. */
dispatch: () => Promise<R>;
}): Promise<OperationOutcome<R>> {
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
try {
const won = await input.store.start(input.identity, input.fingerprint);
if (!won) {
const stored = await input.store.receipt(input.identity, input.fingerprint);
return stored !== undefined
? { kind: "replayed", receipt: stored }
: { kind: "replayed", receipt: undefined as unknown as R };
}
} catch (error) {
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
}
const receipt = await input.dispatch();
try {
await input.store.save(input.identity, input.fingerprint, receipt);
} catch {
/* The downstream commit is real; a journal-write failure must not turn a
real receipt into an error. The next retry replays via the stored receipt
if the save ever succeeds before a downstream resend. */
}
return { kind: "claimed", execute: async () => receipt };
}
+2 -235
View File
@@ -1,235 +1,2 @@
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
export * from './relationships';
export * from './idempotency';
+235
View File
@@ -0,0 +1,235 @@
/**
* @siax/access — universal project/tenant relationship checks for SIAX Cloud.
*
* This is the reusable extraction of the pattern first proven in DOC0's
* `src/clients/project-relationships.ts`. It is deliberately framework-neutral
* (no Next.js, Fastify, Prisma or product types) so every SIAX product can
* consume the exact same authorization primitive instead of copying it:
*
* 0pt — document upload + structuring scope
* DOC0 — document/version/signature scope
* N0D — job submission scope
* any app — tenant/project membership
*
* IMPORTANT: a relationship match is ONE input to authorization, never a
* complete decision. The caller must independently bind tenant and actor to a
* verified principal (never to request headers), enforce tenant/project
* status, capability, data-handling policy and an authoritative tuple
* lifecycle. `resolveRelationship` fails closed: only an explicit
* `relationships_match` is treated as allowed.
*/
export type RelationshipResult = 'allowed' | 'denied' | 'unavailable';
/** The outcome of the composite project check. */
export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable';
export interface ProjectRelationshipScope {
tenantId: string;
projectId: string;
actorId: string;
}
const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const maxResponseBytes = 16 * 1024;
export interface OpenFgaRelationshipsOptions {
baseUrl: string;
storeId: string;
modelId: string;
serviceToken: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
/** Explicit test-only opt-in for an isolated loopback OpenFGA process. */
testOnlyAllowLoopbackHttp?: boolean;
}
/**
* Read-only OpenFGA relationship reader. Never writes tuples, never caches,
* and never retries: an unavailable authorization source must stay visible to
* the caller so it can fail closed.
*/
export class OpenFgaRelationships {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly storeId: string;
private readonly modelId: string;
private readonly serviceToken: string;
private readonly timeoutMs: number;
constructor(options: OpenFgaRelationshipsOptions) {
const url = new URL(options.baseUrl);
const loopbackTest =
process.env.NODE_ENV === 'test' &&
options.testOnlyAllowLoopbackHttp === true &&
url.protocol === 'http:' &&
url.hostname === '127.0.0.1';
if (
(!loopbackTest && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
typeof options.storeId !== 'string' ||
typeof options.modelId !== 'string' ||
!ulid.test(options.storeId) ||
!ulid.test(options.modelId) ||
typeof options.serviceToken !== 'string' ||
!/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) ||
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000))
) {
throw new Error('Invalid trusted OpenFGA configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.storeId = options.storeId;
this.modelId = options.modelId;
this.serviceToken = options.serviceToken;
this.timeoutMs = options.timeoutMs ?? 5_000;
}
/**
* Both checks are necessary: project membership alone may inherit from a
* different parent tenant if bad tuples associate a project with two
* tenants. Returns `relationships_match` only when both hold.
*/
async checkProject(scope: ProjectRelationshipScope): Promise<ProjectRelationshipResult> {
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
return 'denied';
}
const { tenantId, projectId, actorId } = scope;
if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) {
return 'denied';
}
const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`);
if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable';
const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`);
return parent === 'allowed' ? 'relationships_match' : parent;
}
/** One raw relationship check. Public so products can map their own tuples. */
async checkTuple(user: string, relation: string, object: string): Promise<RelationshipResult> {
if (
![user, relation, object].every(
(value) => typeof value === 'string' && value.length > 0 && value.length <= 256,
)
) {
return 'denied';
}
try {
const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.serviceToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
authorization_model_id: this.modelId,
consistency: 'HIGHER_CONSISTENCY',
tuple_key: { user, relation, object },
}),
redirect: 'error',
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok || !response.body) return 'unavailable';
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.byteLength;
if (total > maxResponseBytes) {
void reader.cancel().catch(() => undefined);
return 'unavailable';
}
chunks.push(part.value);
}
const result: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)),
);
if (
!result ||
typeof result !== 'object' ||
Array.isArray(result) ||
typeof (result as { allowed?: unknown }).allowed !== 'boolean'
) {
return 'unavailable';
}
return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied';
} finally {
reader.releaseLock();
}
} catch {
return 'unavailable';
}
}
}
/**
* Canonical environment variable names used across the fleet. Reads are
* explicit (no implicit defaults) so a missing value is visible.
*/
export const OPENFGA_ENV = {
baseUrl: 'OPENFGA_API_URL',
storeId: 'OPENFGA_STORE_ID',
modelId: 'OPENFGA_MODEL_ID',
serviceToken: 'OPENFGA_AUTH_TOKEN',
} as const;
export interface ProjectAccessOptions {
/** The already-configured relationship reader, or undefined when OpenFGA is not configured. */
relationships?: OpenFgaRelationships;
/**
* Optional extra policy (project status, capability, data-handling). It runs
* only after the relationship check matched; a rejection is final.
*/
additionalPolicy?: (scope: ProjectRelationshipScope) => Promise<boolean> | boolean;
}
/** Fail closed: only an explicit match with no policy objection is allowed. */
export async function isProjectAccessAllowed(
scope: ProjectRelationshipScope,
options: ProjectAccessOptions,
): Promise<boolean> {
if (!options.relationships) return false;
const result = await options.relationships.checkProject(scope);
if (result !== 'relationships_match') return false;
if (options.additionalPolicy) {
try {
return (await options.additionalPolicy(scope)) === true;
} catch {
return false;
}
}
return true;
}
/**
* Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object.
* Returns `undefined` (fail closed) when any value is missing or invalid.
*/
export function openFgaFromEnv(
env: NodeJS.ProcessEnv = process.env,
fetchImpl?: typeof fetch,
): OpenFgaRelationships | undefined {
const baseUrl = env[OPENFGA_ENV.baseUrl];
const storeId = env[OPENFGA_ENV.storeId];
const modelId = env[OPENFGA_ENV.modelId];
const serviceToken = env[OPENFGA_ENV.serviceToken];
if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined;
try {
return new OpenFgaRelationships({
baseUrl,
storeId,
modelId,
serviceToken,
...(fetchImpl ? { fetchImpl } : {}),
});
} catch {
return undefined;
}
}
+56 -22
View File
@@ -108,6 +108,9 @@ importers:
'@siax/access':
specifier: workspace:*
version: link:packages/siax-access
'@siax/auth-zitadel':
specifier: 0.1.1
version: 0.1.1
'@siax/charts':
specifier: ^0.5.0
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -118,8 +121,8 @@ importers:
specifier: ^0.2.1
version: 0.2.1(tailwindcss@4.3.3)
'@siax/contracts':
specifier: ^0.11.1
version: 0.11.1
specifier: ^0.12.1
version: 0.12.1
'@siax/import-export':
specifier: ^0.2.2
version: 0.2.2
@@ -127,8 +130,8 @@ importers:
specifier: ^0.1.2
version: 0.1.2
'@siax/ui':
specifier: ^0.15.1
version: 0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
specifier: ^0.16.13
version: 0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
autoprefixer:
specifier: ^10.4.20
version: 10.5.5(postcss@8.5.28)
@@ -1546,14 +1549,23 @@ packages:
cpu: [x64]
os: [win32]
'@siax/charts@0.5.0':
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
'@siax/analytical-context@0.5.1':
resolution: {integrity: sha512-bqc2NGPyzLVcu4VUgz1XYsyghSM4c3ck5gBVFSzeRYMhe2cj0nf5Fm5n/sFaOuhMaKdDnX/TIR2FO6f21lgufg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fanalytical-context/-/0.5.1/analytical-context-0.5.1.tgz}
'@siax/auth-core@0.1.0':
resolution: {integrity: sha512-oTfEO4S6pv3rQ6bSh44+fRWPiwGr9nYCf6uWSBF8g5PFUspW6nr4wKQZVAkLNwfC0Pu6o+CH8ltsuu+fY8gOQA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-core/-/0.1.0/auth-core-0.1.0.tgz}
'@siax/auth-zitadel@0.1.1':
resolution: {integrity: sha512-SmoBswG5P7szZIaETqmo1NN5RhdXLq2RDR/1wObfxHM2yUEXNSZQJznZoAb8z3lm/PvBgQhmnHiOHCfidN1ZQg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fauth-zitadel/-/0.1.1/auth-zitadel-0.1.1.tgz}
'@siax/charts@0.11.0':
resolution: {integrity: sha512-IQnby+h6jVMaRAM7DcfUMuMbSUjd8B3+AGk9N4pXk2GDudMRMjt0bTtu75637Jc1Mf4iNonvgp8ytKlS7KdW4A==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.11.0/charts-0.11.0.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
'@siax/charts@0.5.1':
resolution: {integrity: sha512-SFo6771jplE3fpUu2IsK11NipM5iAlM2h1N7jdVgsqqs3do+TnOi2knhIPSpUBhKfh5bZwMvO5v2TsrZmtsO+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.1/charts-0.5.1.tgz}
'@siax/charts@0.5.0':
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
@@ -1569,14 +1581,20 @@ packages:
'@siax/contracts@0.11.1':
resolution: {integrity: sha512-sn0ZlprJE8LcbKF4PF8wBOhdOfygOG/JbU7nB+NDFBBP/AhtvMXcxFGl1oX0GadifD7VqZrVSoCliQqY/V/HYg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.11.1/contracts-0.11.1.tgz}
'@siax/contracts@0.12.1':
resolution: {integrity: sha512-gSvMYRSxfbPPAqUGRjUMO1s38E4LdmIzDEpiNNIiGVminDHHwCOCybnhf2YNYaIfm6VBYoq4rP4yCERrpJregA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.12.1/contracts-0.12.1.tgz}
'@siax/import-export@0.2.2':
resolution: {integrity: sha512-mb2fM6lVjWh6cVdoBFOLTdyjYRtjMjlSxNtpNHZHdW51tDrNZRNZc0Mz+lVIFsJHQ79NbL2J8GsWpHmL/rVBzg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.2/import-export-0.2.2.tgz}
'@siax/import-export@0.2.4':
resolution: {integrity: sha512-xYRLF/5B7EyK3jbZCsPvDcEhZRGC/72QQpkEIsFJQqKwUxsejiQ1RabjzLoF0wNjIKlubEYnkfxDBaJMeIgKrg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.4/import-export-0.2.4.tgz}
'@siax/tokens@0.1.2':
resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz}
'@siax/ui@0.15.1':
resolution: {integrity: sha512-GeU+Iwj7s3cWJUHfvt0r07dWGCGvh9jG9cS9D4EkdKb+EQGhOns2IGbDNPYRkLj/t55Qz2fjSrYCMwXO19zPDw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.15.1/ui-0.15.1.tgz}
'@siax/ui@0.16.13':
resolution: {integrity: sha512-qWmGexMa7kqol5O3MBDkraF/s5APAahNyGHQusDZ34YDZvGnlr3t7raWIVJMFHgz6D0Fsgyuak6210Ktn3SpHA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.16.13/ui-0.16.13.tgz}
peerDependencies:
react: ^19.2.0
react-dom: ^19.2.0
@@ -4225,16 +4243,17 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
clsx: 2.1.1
echarts: 6.1.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tailwind-merge: 2.6.1
'@siax/analytical-context@0.5.1': {}
'@siax/charts@0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
'@siax/auth-core@0.1.0': {}
'@siax/auth-zitadel@0.1.1':
dependencies:
'@siax/auth-core': 0.1.0
'@siax/charts@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
dependencies:
'@siax/analytical-context': 0.5.1
'@siax/config': 0.2.1(tailwindcss@4.3.3)
clsx: 2.1.1
echarts: 6.1.0
@@ -4244,6 +4263,14 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
clsx: 2.1.1
echarts: 6.1.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tailwind-merge: 2.6.1
'@siax/collaboration@0.3.0': {}
'@siax/config@0.2.1(tailwindcss@4.3.3)':
@@ -4252,13 +4279,19 @@ snapshots:
'@siax/contracts@0.11.1': {}
'@siax/contracts@0.12.1': {}
'@siax/import-export@0.2.2':
dependencies:
'@siax/contracts': 0.11.1
'@siax/import-export@0.2.4':
dependencies:
'@siax/contracts': 0.12.1
'@siax/tokens@0.1.2': {}
'@siax/ui@0.15.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
'@siax/ui@0.16.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
dependencies:
'@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -4281,10 +4314,11 @@ snapshots:
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@siax/charts': 0.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
'@siax/analytical-context': 0.5.1
'@siax/charts': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
'@siax/config': 0.2.1(tailwindcss@4.3.3)
'@siax/contracts': 0.11.1
'@siax/import-export': 0.2.2
'@siax/contracts': 0.12.1
'@siax/import-export': 0.2.4
'@siax/tokens': 0.1.2
'@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py ingest_aud0.py ./
EXPOSE 8096
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8096/health', timeout=3)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8096"]
+47
View File
@@ -0,0 +1,47 @@
# 0pt analytics HTTP surface
The missing read plane between API0's `/analytics/*` contract and the
`opt_analytics` ClickHouse database.
## Surface
Serves the BI-P068 operation paths under `/api/0pt/analytics`:
- `GET /health`
- `GET /datasets`, `GET /datasets/{id}`
- `GET /metrics`, `GET /metrics/{id}`, `GET /metrics/{id}/explain`
- `POST /metrics/{id}/query`
- `POST /query`, `POST /query/estimate`, `POST /query/explain`
- `GET /query/{id}` (404: executions are not persisted yet)
- `GET /dashboards`, `GET /reports` (empty until the BI metadata lane writes them)
- `GET /search`
Identity: the caller's Keycloak token is verified against the portal realm
JWKS; `tenant_id` is taken from the token and cross-checked against
`X-Tenant-Id`. An `X-Internal-Token` path exists only for the ingestion worker.
## Ingest
`ingest_aud0.py` maps AUD0 audit events to `opt_analytics_event`
(`dataset_id=aud0.events`, `metric=event_count`, dimensions event_type/app_id/
capability_id/risk_level). It is idempotent per `event_id`.
```bash
AUD0_TOKEN=... AUD0_TENANT_ID=... CLICKHOUSE_URL=http://opt-analytics-clickhouse:8123 \
python ingest_aud0.py --limit 50000
```
## Run
```bash
pip install -r requirements.txt
CLICKHOUSE_URL=http://opt-analytics-clickhouse:8123 \
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal \
uvicorn app:app --host 0.0.0.0 --port 8096
```
## Test
```bash
python -m pytest tests -q
```
+464
View File
@@ -0,0 +1,464 @@
"""
0pt analytics HTTP surface (BI-P068-compatible).
This is the missing read plane between API0's `/analytics/*` contract and the
`opt_analytics` ClickHouse database. It does not invent a second analytics
model:
* datasets/metrics are the distinct ids actually present in
`opt_analytics_event` / `opt_metric_daily` / `opt_dataset_projection`;
* query/estimate/explain are computed against the tenant-forced ClickHouse
rows, never against a local cache;
* dashboards/reports are honest empty lists until the 0pt BI metadata lane
writes them;
* every response is either real ClickHouse data or an explicit error.
IDENTITY. API0 verifies the caller and forwards the caller's own Keycloak
token plus `X-Tenant-Id`. This service verifies the same token against the
portal realm's JWKS and cross-checks `tenant_id` against `X-Tenant-Id`. A
service-to-service `X-Internal-Token` is also accepted for the ingestion
worker, but never for a request that reads tenant data without an explicit,
already-verified `X-Tenant-Id`.
"""
from __future__ import annotations
import os
import time
from functools import lru_cache
from typing import Any, Iterable, Literal
import httpx
import jwt
from fastapi import FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
CLICKHOUSE_URL = os.getenv("CLICKHOUSE_URL", "").rstrip("/")
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
CLICKHOUSE_DATABASE = os.getenv("CLICKHOUSE_DATABASE", "opt_analytics")
KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", "").rstrip("/")
KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account")
KEYCLOAK_AUTHORIZED_PARTY = os.getenv("KEYCLOAK_AUTHORIZED_PARTY", "siax-portal")
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
ALLOWED_DIMENSIONS = {"event_type", "app_id", "capability_id", "risk_level", "source_service"}
app = FastAPI(title="0pt analytics", version="0.1")
class AnalyticsError(Exception):
def __init__(self, code: str, message: str, status: int = 400):
self.code = code
self.message = message
self.status = status
super().__init__(message)
@app.exception_handler(AnalyticsError)
async def analytics_error_handler(_request: Request, exc: AnalyticsError) -> JSONResponse:
return JSONResponse(status_code=exc.status, content={"error": {"code": exc.code, "message": exc.message}})
# ── identity ────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _jwks_client() -> jwt.PyJWKClient:
if not KEYCLOAK_ISSUER:
raise AnalyticsError("ANALYTICS_NOT_CONFIGURED", "KEYCLOAK_ISSUER is not set", 503)
return jwt.PyJWKClient(f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs", cache_keys=True, lifespan=3600)
def _bearer(request: Request) -> str | None:
raw = request.headers.get("authorization", "")
if not raw.lower().startswith("bearer "):
return None
return raw[7:].strip() or None
def verified_identity(request: Request, x_tenant_id: str | None) -> str:
"""Return the tenant the request is allowed to read.
An `X-Internal-Token` may only be used by the ingestion worker and never
substitutes for a tenant claim. For user/API0 calls the JWT is the source
of truth; a mismatching X-Tenant-Id is a hard 403, not a preference.
"""
if INTERNAL_TOKEN and request.headers.get("x-internal-token") == INTERNAL_TOKEN:
if not x_tenant_id:
raise AnalyticsError("TENANT_REQUIRED", "X-Tenant-Id is required for internal calls", 400)
return x_tenant_id
token = _bearer(request)
if not token:
raise AnalyticsError("UNAUTHENTICATED", "A bearer token is required", 401)
try:
signing_key = _jwks_client().get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "RS384", "RS512"],
audience=KEYCLOAK_AUDIENCE,
issuer=KEYCLOAK_ISSUER,
options={"require": ["exp", "iss", "sub"]},
)
except jwt.PyJWTError as exc:
raise AnalyticsError("UNAUTHENTICATED", f"Invalid bearer token: {exc}", 401) from exc
if KEYCLOAK_AUTHORIZED_PARTY and claims.get("azp") not in (None, KEYCLOAK_AUTHORIZED_PARTY):
raise AnalyticsError("FORBIDDEN", "Token was issued for a different client", 403)
tenant = claims.get("tenant_id")
if not isinstance(tenant, str) or not tenant.strip():
raise AnalyticsError("NO_TENANT", "Token is not bound to a tenant", 403)
if x_tenant_id and x_tenant_id != tenant:
raise AnalyticsError("TENANT_MISMATCH", "X-Tenant-Id does not match the verified tenant", 403)
return tenant
# ── clickhouse ──────────────────────────────────────────────────────────────
def _ch_client() -> httpx.Client:
if not CLICKHOUSE_URL:
raise AnalyticsError("ANALYTICS_NOT_CONFIGURED", "CLICKHOUSE_URL is not set", 503)
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
return httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=20.0)
def ch_json(sql: str, params: dict[str, str] | None = None) -> dict[str, Any]:
params = params or {}
query_params = {
"database": CLICKHOUSE_DATABASE,
"output_format_json_quote_64bit_integers": "0",
**{f"param_{key}": value for key, value in params.items()},
}
try:
with _ch_client() as client:
response = client.post("/", params=query_params, content=sql)
except httpx.HTTPError as exc:
raise AnalyticsError("CLICKHOUSE_UNREACHABLE", str(exc), 503) from exc
if response.status_code != 200:
raise AnalyticsError("CLICKHOUSE_REFUSED", response.text[:500], 502)
try:
return response.json()
except ValueError as exc:
raise AnalyticsError("CLICKHOUSE_BAD_RESPONSE", "ClickHouse did not return JSON", 502) from exc
def _literal(value: str) -> str:
# ClickHouse query parameters are preferable; the only values interpolated
# here are identifiers from ALLOWED_DIMENSIONS, never caller text.
if value not in ALLOWED_DIMENSIONS:
raise AnalyticsError("INVALID_DIMENSION", f"Unsupported dimension: {value}")
return value
def rows(sql: str, params: dict[str, str] | None = None) -> list[dict[str, Any]]:
return ch_json(sql, params).get("data", [])
def scalar(sql: str, params: dict[str, str] | None = None) -> Any:
data = rows(sql, params)
if not data:
return None
return next(iter(data[0].values()))
def build_query(
*,
tenant: str,
dataset: str | None,
metrics: Iterable[str],
dimensions: Iterable[str],
filters: dict[str, str] | None,
from_time: str | None,
to_time: str | None,
limit: int,
) -> tuple[str, dict[str, str]]:
dims = [_literal(d) for d in dimensions]
metric_list = [m for m in metrics if m.strip()]
if not metric_list:
raise AnalyticsError("INVALID_REQUEST", "At least one metric is required")
where = ["tenant_id = {tenant:String}"]
params: dict[str, str] = {"tenant": tenant, "limit": str(max(1, min(limit, 1000)))}
if dataset:
where.append("dataset_id = {dataset:String}")
params["dataset"] = dataset
metric_params = []
for i, metric in enumerate(metric_list):
key = f"metric{i}"
metric_params.append(f"{{{key}:String}}")
params[key] = metric
where.append(f"metric IN ({', '.join(metric_params)})")
for i, (key, value) in enumerate((filters or {}).items()):
dim = _literal(key)
p = f"filter{i}"
where.append(f"dim_values[indexOf(dim_names, '{dim}')] = {{{p}:String}}")
params[p] = value
if from_time:
where.append("event_time >= {from:String}")
params["from"] = from_time
if to_time:
where.append("event_time <= {to:String}")
params["to"] = to_time
select_dims = []
for i, dim in enumerate(dims):
select_dims.append(f"dim_values[indexOf(dim_names, '{dim}')] AS d{i}")
select = ", ".join(select_dims + ["count() AS value"])
group = f" GROUP BY {', '.join(f'd{i}' for i in range(len(dims)))}" if dims else ""
sql = (
f"SELECT {select} FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
f"WHERE {' AND '.join(where)}{group} ORDER BY value DESC LIMIT {{limit:UInt32}} FORMAT JSON"
)
return sql, params
# ── models ─────────────────────────────────────────────────────────────────
class QueryRequest(BaseModel):
dataset: str | None = None
metrics: list[str] = Field(default_factory=list)
dimensions: list[str] = Field(default_factory=list)
filters: dict[str, str] | None = None
timeRange: dict[str, str] | None = None
limit: int = 100
# ── surface ────────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> dict[str, Any]:
try:
ch_json("SELECT 1")
return {"status": "ok", "providers": {"clickhouse": {"status": "ok"}}}
except AnalyticsError as exc:
return {"status": "error", "providers": {"clickhouse": {"status": "error", "detail": exc.message}}}
@app.get("/api/0pt/analytics/health")
def analytics_health() -> dict[str, Any]:
return health()
@app.get("/api/0pt/analytics/datasets")
def list_datasets(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
tenant = verified_identity(request, x_tenant_id)
rows_ = rows(
f"SELECT dataset_id, count() AS events FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
"WHERE tenant_id = {tenant:String} GROUP BY dataset_id ORDER BY dataset_id FORMAT JSON",
{"tenant": tenant},
)
if not rows_:
rows_ = rows(
f"SELECT dataset_id, count() AS rows FROM {CLICKHOUSE_DATABASE}.opt_dataset_projection "
"WHERE tenant_id = {tenant:String} GROUP BY dataset_id ORDER BY dataset_id FORMAT JSON",
{"tenant": tenant},
)
return [
{
"id": r["dataset_id"],
"name": r["dataset_id"],
"tier": "SERVING",
"owner": "0pt",
"source": "0pt analytics",
"tenant_id": tenant,
**({"events": r.get("events")} if "events" in r else {}),
**({"rows": r.get("rows")} if "rows" in r else {}),
}
for r in rows_
]
@app.get("/api/0pt/analytics/datasets/{dataset_id}")
def get_dataset(dataset_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
found = [d for d in list_datasets(request, x_tenant_id) if d["id"] == dataset_id]
if not found:
raise AnalyticsError("NOT_FOUND", f"dataset {dataset_id} does not exist for this tenant", 404)
return found[0]
@app.get("/api/0pt/analytics/metrics")
def list_metrics(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
tenant = verified_identity(request, x_tenant_id)
rows_ = rows(
f"SELECT metric, count() AS events FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
"WHERE tenant_id = {tenant:String} GROUP BY metric ORDER BY metric FORMAT JSON",
{"tenant": tenant},
)
if not rows_:
rows_ = rows(
f"SELECT metric, sum(count_state) AS events FROM {CLICKHOUSE_DATABASE}.opt_metric_daily "
"WHERE tenant_id = {tenant:String} GROUP BY metric ORDER BY metric FORMAT JSON",
{"tenant": tenant},
)
dims = rows(
f"SELECT DISTINCT arrayJoin(dim_names) AS name FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
"WHERE tenant_id = {tenant:String} FORMAT JSON",
{"tenant": tenant},
)
dimension_defs = [{"name": d["name"], "type": "string"} for d in dims]
return [
{
"id": r["metric"],
"name": r["metric"],
"description": f"0pt analytics metric {r['metric']}",
"datasets": ["aud0.events"],
"dimensions": dimension_defs,
"tenant_id": tenant,
**({"events": r.get("events")} if "events" in r else {}),
}
for r in rows_
]
@app.get("/api/0pt/analytics/metrics/{metric_id}")
def get_metric(metric_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
found = [m for m in list_metrics(request, x_tenant_id) if m["id"] == metric_id]
if not found:
raise AnalyticsError("NOT_FOUND", f"metric {metric_id} does not exist for this tenant", 404)
return found[0]
def _run_query(request: Request, x_tenant_id: str | None, body: QueryRequest, metric: str | None = None) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
metrics = [metric] if metric else body.metrics
dataset = body.dataset or "aud0.events"
sql, params = build_query(
tenant=tenant,
dataset=dataset,
metrics=metrics,
dimensions=body.dimensions,
filters=body.filters,
from_time=(body.timeRange or {}).get("from"),
to_time=(body.timeRange or {}).get("to"),
limit=body.limit,
)
started = time.time()
data = ch_json(sql, params)
result_rows = data.get("data", [])
duration_ms = int((time.time() - started) * 1000)
columns = []
if result_rows:
columns = [{"name": key, "type": "string" if key.startswith("d") else "number"} for key in result_rows[0].keys()]
return {
"query_id": f"ch-{int(time.time() * 1000)}",
"status": "succeeded",
"engine": "clickhouse",
"submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"duration": duration_ms,
"rows": len(result_rows),
"tenant_id": tenant,
"result": {"columns": columns, "rows": result_rows, "truncated": len(result_rows) >= body.limit},
}
@app.post("/api/0pt/analytics/query")
def execute_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
return _run_query(request, x_tenant_id, body)
@app.post("/api/0pt/analytics/metrics/{metric_id}/query")
def execute_metric(metric_id: str, body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
return _run_query(request, x_tenant_id, body, metric=metric_id)
@app.post("/api/0pt/analytics/query/estimate")
def estimate_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
sql, params = build_query(
tenant=tenant,
dataset=body.dataset or "aud0.events",
metrics=body.metrics or ["event_count"],
dimensions=body.dimensions,
filters=body.filters,
from_time=(body.timeRange or {}).get("from"),
to_time=(body.timeRange or {}).get("to"),
limit=body.limit,
)
count = scalar(
f"SELECT count() FROM {CLICKHOUSE_DATABASE}.opt_analytics_event WHERE tenant_id = {{tenant:String}} FORMAT JSON",
{"tenant": tenant},
)
return {"estimated_rows_to_read": count or 0, "within_budget": True, "rows": count or 0}
@app.post("/api/0pt/analytics/query/explain")
def explain_query(body: QueryRequest, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
sql, _params = build_query(
tenant=tenant,
dataset=body.dataset or "aud0.events",
metrics=body.metrics or ["event_count"],
dimensions=body.dimensions,
filters=body.filters,
from_time=(body.timeRange or {}).get("from"),
to_time=(body.timeRange or {}).get("to"),
limit=body.limit,
)
return {"plan": sql, "forced_tenant_predicate": "tenant_id = {tenant:String}", "table": "opt_analytics_event"}
@app.get("/api/0pt/analytics/metrics/{metric_id}/explain")
def explain_metric(metric_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
return explain_query(QueryRequest(metrics=[metric_id]), request, x_tenant_id)
@app.get("/api/0pt/analytics/dashboards")
def list_dashboards(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
verified_identity(request, x_tenant_id)
return []
@app.get("/api/0pt/analytics/dashboards/{dashboard_id}")
def get_dashboard(dashboard_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
verified_identity(request, x_tenant_id)
raise AnalyticsError("NOT_FOUND", f"dashboard {dashboard_id} does not exist", 404)
@app.get("/api/0pt/analytics/reports")
def list_reports(request: Request, x_tenant_id: str | None = Header(default=None)) -> list[dict[str, Any]]:
verified_identity(request, x_tenant_id)
return []
@app.get("/api/0pt/analytics/reports/{report_id}")
def get_report(report_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
verified_identity(request, x_tenant_id)
raise AnalyticsError("NOT_FOUND", f"report {report_id} does not exist", 404)
@app.get("/api/0pt/analytics/search")
def search_catalog(
request: Request,
q: str = Query(default=""),
types: str = Query(default=""),
limit: int = Query(default=20, ge=1, le=100),
x_tenant_id: str | None = Header(default=None),
) -> dict[str, Any]:
tenant = verified_identity(request, x_tenant_id)
wanted = {t.strip() for t in types.split(",") if t.strip()}
hits: list[dict[str, Any]] = []
for dataset in list_datasets(request, x_tenant_id):
if (not wanted or "dataset" in wanted) and (not q or q.lower() in dataset["id"].lower()):
hits.append({**dataset, "type": "dataset", "canonical": False, "canonicalOperation": "getDataset"})
for metric in list_metrics(request, x_tenant_id):
if (not wanted or "metric" in wanted) and (not q or q.lower() in metric["id"].lower()):
hits.append({**metric, "type": "metric", "canonical": False, "canonicalOperation": "getMetric"})
return {"query": q, "derived": True, "hits": hits[:limit], "tenant_id": tenant}
@app.get("/api/0pt/analytics/query/{query_id}")
def get_query(query_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
verified_identity(request, x_tenant_id)
raise AnalyticsError("NOT_FOUND", f"query {query_id} is not persisted", 404)
@app.post("/api/0pt/analytics/query/{query_id}/cancel")
def cancel_query(query_id: str, request: Request, x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
verified_identity(request, x_tenant_id)
raise AnalyticsError("NOT_FOUND", f"query {query_id} is not running", 404)
+154
View File
@@ -0,0 +1,154 @@
"""
Backfill/refresh: AUD0 events -> 0pt ClickHouse analytics events.
One audit event becomes one analytics event with metric `event_count` and
dimensions `event_type`, `app_id`, `capability_id`, `risk_level`. The write is
idempotent per event_id: already-present ids are skipped before the batch is
inserted. No synthetic event is ever created; an empty AUD0 page writes
nothing.
Usage:
AUD0_TOKEN=... AUD0_TENANT_ID=379... CLICKHOUSE_URL=http://... \
python ingest_aud0.py --limit 50000
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any, Iterable
import httpx
AUD0_API_URL = os.getenv("AUD0_API_URL", "https://api.aud0.siax.io").rstrip("/")
AUD0_TOKEN = os.getenv("AUD0_TOKEN", "")
AUD0_TENANT_ID = os.getenv("AUD0_TENANT_ID", "")
CLICKHOUSE_URL = os.getenv("CLICKHOUSE_URL", "").rstrip("/")
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
CLICKHOUSE_DATABASE = os.getenv("CLICKHOUSE_DATABASE", "opt_analytics")
def dims(event: dict[str, Any]) -> tuple[list[str], list[str]]:
names: list[str] = []
values: list[str] = []
for key in ("event_type", "app_id", "capability_id", "risk_level"):
value = event.get(key)
if isinstance(value, str) and value:
names.append(key)
values.append(value)
return names, values
def to_row(tenant: str, event: dict[str, Any]) -> dict[str, Any] | None:
event_id = event.get("event_id")
created_at = event.get("created_at")
event_type = event.get("event_type")
if not isinstance(event_id, str) or not event_id:
return None
if not isinstance(created_at, str) or not created_at:
return None
if not isinstance(event_type, str) or not event_type:
return None
names, values = dims(event)
return {
"tenant_id": tenant,
"project_id": "aud0",
"dataset_id": "aud0.events",
"metric": "event_count",
"event_id": event_id,
"event_time": created_at,
"value": 1,
"dim_names": names,
"dim_values": values,
}
def ch_insert(rows: Iterable[dict[str, Any]]) -> None:
payload = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
if not payload:
return
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
with httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=60.0) as client:
response = client.post(
"/",
params={"database": CLICKHOUSE_DATABASE, "query": "INSERT INTO opt_analytics_event FORMAT JSONEachRow"},
content=payload,
)
response.raise_for_status()
def existing_ids(tenant: str, ids: list[str]) -> set[str]:
if not ids:
return set()
auth = (CLICKHOUSE_USER, CLICKHOUSE_PASSWORD) if CLICKHOUSE_PASSWORD else None
found: set[str] = set()
sql = (
f"SELECT event_id FROM {CLICKHOUSE_DATABASE}.opt_analytics_event "
"WHERE tenant_id = {tenant:String} AND event_id IN ({ids:Array(String)}) FORMAT JSON"
)
with httpx.Client(base_url=CLICKHOUSE_URL, auth=auth, timeout=30.0) as client:
# ClickHouse query parameters travel in the URL; 1000 ids overflow it.
for start in range(0, len(ids), 50):
chunk = ids[start : start + 50]
response = client.post(
"/",
params={
"database": CLICKHOUSE_DATABASE,
"param_tenant": tenant,
"param_ids": "[" + ",".join("'" + value.replace("'", "\\'") + "'" for value in chunk) + "]",
},
content=sql,
)
response.raise_for_status()
found.update(row["event_id"] for row in response.json().get("data", []))
return found
def fetch_page(offset: int, limit: int) -> dict[str, Any]:
if not AUD0_TOKEN or not AUD0_TENANT_ID:
raise SystemExit("AUD0_TOKEN and AUD0_TENANT_ID are required")
with httpx.Client(base_url=AUD0_API_URL, timeout=30.0) as client:
response = client.get(
"/v1/aud0/events",
params={"tenant_id": AUD0_TENANT_ID, "limit": limit, "offset": offset},
headers={"authorization": f"Bearer {AUD0_TOKEN}", "x-tenant-id": AUD0_TENANT_ID},
)
response.raise_for_status()
return response.json()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=50_000, help="maximum events to read")
parser.add_argument("--page", type=int, default=1000)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not CLICKHOUSE_URL:
raise SystemExit("CLICKHOUSE_URL is required")
written = skipped = seen = 0
for offset in range(0, args.limit, args.page):
page = fetch_page(offset, min(args.page, args.limit - offset))
events = page.get("events") or []
if not events:
break
rows = [row for row in (to_row(AUD0_TENANT_ID, event) for event in events) if row]
present = existing_ids(AUD0_TENANT_ID, [row["event_id"] for row in rows])
fresh = [row for row in rows if row["event_id"] not in present]
skipped += len(rows) - len(fresh)
seen += len(events)
if not args.dry_run:
ch_insert(fresh)
written += len(fresh)
print(json.dumps({"offset": offset, "read": len(events), "written": len(fresh), "skipped": skipped}))
if len(events) < args.page:
break
print(json.dumps({"seen": seen, "written": written, "skipped": skipped, "dry_run": args.dry_run}))
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
PyJWT[crypto]==2.14.0
pytest==8.3.4
+52
View File
@@ -0,0 +1,52 @@
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import app as analytics # noqa: E402
def test_build_query_forces_tenant_and_dimension_whitelist():
sql, params = analytics.build_query(
tenant="379",
dataset="aud0.events",
metrics=["event_count"],
dimensions=["event_type"],
filters={"app_id": "portal"},
from_time="2026-01-01T00:00:00Z",
to_time=None,
limit=50,
)
assert "tenant_id = {tenant:String}" in sql
assert "dataset_id = {dataset:String}" in sql
assert "dim_values[indexOf(dim_names, 'event_type')]" in sql
assert params["tenant"] == "379"
assert params["dataset"] == "aud0.events"
assert params["metric0"] == "event_count"
assert params["filter0"] == "portal"
with pytest.raises(analytics.AnalyticsError):
analytics.build_query(
tenant="379",
dataset=None,
metrics=["event_count"],
dimensions=["drop table"],
filters=None,
from_time=None,
to_time=None,
limit=10,
)
def test_data_routes_require_a_verified_identity():
client = TestClient(analytics.app)
assert client.get("/api/0pt/analytics/datasets").status_code == 401
assert client.get("/api/0pt/analytics/metrics").status_code == 401
def test_health_is_always_a_liveness_answer():
client = TestClient(analytics.app)
response = client.get("/health")
assert response.status_code == 200
assert "providers" in response.json()
+28 -14
View File
@@ -71,25 +71,39 @@
"canonical": true,
"parentRepo": null,
"capabilities": [
"prospekt.email_verify",
"prospekt.jobs_write",
"prospekt.jobs_read",
"prospekt.search_read",
"prospekt.search_write",
"0pt.query_source",
"0pt.catalog_list",
"0pt.crawl_job",
"0pt.document_ocr",
"0pt.write_external_record",
"0pt.estimate_compute",
"0pt.execute_metric",
"0pt.lakehouse_mapping_manage",
"0pt.lakehouse_write",
"0pt.source_rate_policy_manage",
"0pt.screen_sanctions",
"0pt.estimate_compute",
"0pt.vehicle_lookup",
"0pt.catalog_list",
"0pt.source_registry_list",
"0pt.load_full_table",
"0pt.order_create",
"0pt.order_payment",
"0pt.write_iceberg_table"
"0pt.query_source",
"0pt.render_report",
"0pt.run_data_quality_check",
"0pt.screen_sanctions",
"0pt.source_rate_policy_manage",
"0pt.source_registry_list",
"0pt.structure.extract_document",
"0pt.structure.get_lineage",
"0pt.structure.normalize_value",
"0pt.structure.quality_checks",
"0pt.structure.quality_list_runs",
"0pt.structure.quality_reports",
"0pt.structure.quality_run",
"0pt.structure.resolve_entities",
"0pt.structure.route_parsing",
"0pt.vehicle_lookup",
"0pt.write_external_record",
"0pt.write_iceberg_table",
"prospekt.email_verify",
"prospekt.jobs_read",
"prospekt.jobs_write",
"prospekt.search_read",
"prospekt.search_write"
],
"surfaces": [
"api",
+18
View File
@@ -0,0 +1,18 @@
{
"id": "0pt.crawl_job",
"name": "0pt.crawl_job",
"owner": "0pt",
"version": "0.1.0",
"riskLevel": "L2",
"piiLevel": "low",
"surfaces": [
"api",
"cli",
"mcp",
"job"
],
"requiresJob": true,
"tenantScoped": true,
"operationId": "siax.acquisition.run",
"description": "Ägare 0pt. Registrerad i CL0UD capability-registry (packages/capability-registry/src/acquisition-capabilities.ts, runtime B0TS). operationId är den faktiska kontraktsoperationen siax.acquisition.run, refererad även i 0pt/acquisition.consumer.json — ingen påhittad id."
}
+16
View File
@@ -0,0 +1,16 @@
{
"id": "0pt.execute_metric",
"name": "0pt.execute_metric",
"owner": "0pt",
"version": "0.1.0",
"riskLevel": "L1",
"piiLevel": "none",
"surfaces": [
"api",
"cli"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0ptExecuteMetric",
"description": "Ägare 0pt. Implementation: 0pt/services/analytics/app.py:366 execute_metric, rutt POST /api/0pt/analytics/metrics/{metric_id}/query. Konsumeras av ACT0 consulting_delivery_analytics (governed-metric-steg). operationId är registrerad kontraktsnamn i CL0UD capability-registry (consulting-capabilities.ts)."
}
@@ -0,0 +1,14 @@
{
"id": "0pt.load_full_table",
"name": "0pt.load_full_table",
"owner": "0pt",
"version": "0.1.0",
"riskLevel": "L3",
"piiLevel": "high",
"surfaces": [
"job"
],
"requiresJob": true,
"tenantScoped": true,
"description": "Ägare 0pt. Registrerad i CL0UD capability-registry (0pt-full-table-capabilities.ts); best befintliga jobbgräns 0pt.full_table_job, utförd av p0lytics.docstruct.full_table_worker. Endast jobb-ytan deklareras — ingen HTTP-operation existerar, därför medvetet ingen operationId (registret säger uttryckligen att ingen påhittas)."
}
+1
View File
@@ -12,5 +12,6 @@
],
"requiresJob": true,
"tenantScoped": true,
"operationId": "0pt.create_order",
"description": "Risk tilldelad av RW-005b-regel (L3/low): Produktionsmutation av data (RAG-003 reg 1) — övervakning krävs. RAG-003-RISK-TAXONOMY + siax-tenancy deny-by-default."
}
+1
View File
@@ -12,5 +12,6 @@
],
"requiresJob": true,
"tenantScoped": true,
"operationId": "0pt.query_source",
"description": "Risk tilldelad av RW-005b-regel (L2/none): Läsande/beräknande — ingen mutation, begränsad påverkan. RAG-003-RISK-TAXONOMY + siax-tenancy deny-by-default."
}
+15
View File
@@ -0,0 +1,15 @@
{
"id": "0pt.render_report",
"name": "0pt.render_report",
"owner": "0pt",
"version": "0.1.0",
"riskLevel": "L1",
"piiLevel": "none",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0ptRenderReport",
"description": "Ägare 0pt. ACT0 consulting_delivery_analytics render-steg (artifact-before-notice). Registrerad i CL0UD capability-registry (consulting-capabilities.ts); operationId är registrerad kontraktsnamn, inte en gissning."
}
@@ -0,0 +1,16 @@
{
"id": "0pt.run_data_quality_check",
"name": "0pt.run_data_quality_check",
"owner": "0pt",
"version": "0.1.0",
"riskLevel": "L1",
"piiLevel": "none",
"surfaces": [
"api",
"cli"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0ptRunDataQualityCheck",
"description": "Ägare 0pt. ACT0 consulting_delivery_analytics kvalitetsgrind + seedade analystmallar (tmpl_000010/000011/000575 använder samma capability-id). Registrerad i CL0UD capability-registry (consulting-capabilities.ts)."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.extract_document",
"name": "0pt.structure.extract_document",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L2",
"piiLevel": "high",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.extract_document",
"description": "Canonical capability descriptor for 0pt.structure.extract_document (owner 0pt). Verified against registry + openapi operationId 0pt.extract_document."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.get_lineage",
"name": "0pt.structure.get_lineage",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.get_provenance",
"description": "Canonical capability descriptor for 0pt.structure.get_lineage (owner 0pt). Verified against registry + openapi operationId 0pt.get_provenance."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.normalize_value",
"name": "0pt.structure.normalize_value",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.normalize_value",
"description": "Canonical capability descriptor for 0pt.structure.normalize_value (owner 0pt). Verified against registry + openapi operationId 0pt.normalize_value."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.quality_checks",
"name": "0pt.structure.quality_checks",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.checks",
"description": "Canonical capability descriptor for 0pt.structure.quality_checks (owner 0pt). Verified against registry + openapi operationId 0pt.quality.checks."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.quality_list_runs",
"name": "0pt.structure.quality_list_runs",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.list_runs",
"description": "Canonical capability descriptor for 0pt.structure.quality_list_runs (owner 0pt). Verified against registry + openapi operationId 0pt.quality.list_runs."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.quality_reports",
"name": "0pt.structure.quality_reports",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.reports",
"description": "Canonical capability descriptor for 0pt.structure.quality_reports (owner 0pt). Verified against registry + openapi operationId 0pt.quality.reports."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.quality_run",
"name": "0pt.structure.quality_run",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.quality.run",
"description": "Canonical capability descriptor for 0pt.structure.quality_run (owner 0pt). Verified against registry + openapi operationId 0pt.quality.run."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.resolve_entities",
"name": "0pt.structure.resolve_entities",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L2",
"piiLevel": "high",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.resolve_entities",
"description": "Canonical capability descriptor for 0pt.structure.resolve_entities (owner 0pt). Verified against registry + openapi operationId 0pt.resolve_entities."
}
@@ -0,0 +1,15 @@
{
"id": "0pt.structure.route_parsing",
"name": "0pt.structure.route_parsing",
"owner": "0pt",
"version": "1.0.0",
"riskLevel": "L1",
"piiLevel": "low",
"surfaces": [
"api"
],
"requiresJob": false,
"tenantScoped": true,
"operationId": "0pt.route_parsing",
"description": "Canonical capability descriptor for 0pt.structure.route_parsing (owner 0pt). Verified against registry + openapi operationId 0pt.route_parsing."
}
+821
View File
@@ -0,0 +1,821 @@
{
"schemaVersion": 1,
"repo": "0pt",
"role": "consumer",
"canonicalOwner": "sax3l/DES1GN",
"sourceRoots": [
"app",
"components"
],
"packagePolicy": {
"namespace": "@siax",
"registry": "https://git.siax.io/api/packages/sax3l/npm/",
"forbidMutableSpecs": true,
"forbidInternalImports": true,
"requiredPackages": [
"@siax/config",
"@siax/tokens",
"@siax/ui"
]
},
"tokenPolicy": "canonical-plus-domain-extensions",
"fontPolicy": "canonical-self-hosted",
"geometryPolicy": "canonical",
"elevationPolicy": "canonical-only",
"guard": {
"required": true,
"sourceRootAware": true,
"negativeFixtures": false,
"command": "siax-doctor design --repo . --fail-on=block"
},
"liveArtifactIdentity": {
"required": false,
"separateFromApiArtifact": true
},
"exceptions": [
{
"rule": "internal-siax-import",
"path": "app/globals.css",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/aspect-ratio.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/avatar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/breadcrumb.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/carousel.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/collapsible.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/drawer.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/form.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/label.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/pagination.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/progress.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/resizable.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/scroll-area.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/separator.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/sonner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/spinner.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/table.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toaster.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "local-primitive",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/checkbox.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input-otp.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/radio-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sheet.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/slider.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/switch.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-elevation",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/accordion.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert-dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/alert.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/badge.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/button.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/calendar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/command.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/context-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dialog.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/dropdown-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/empty.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/field.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/hover-card.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/input.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/item.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/kbd.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/menubar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/navigation-menu.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/popover.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/select.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/sidebar.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/skeleton.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tabs.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/textarea.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toast.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle-group.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/toggle.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
},
{
"rule": "raw-radius",
"path": "components/ui/tooltip.tsx",
"reason": "P0 baseline: existing debt frozen; migrate to DES1GN in the P1/P2 wave and remove this exception",
"owner": "0pt",
"expires": "2027-03-31"
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"name": "0pt",
"slug": "0pt",
"origin_url": "https://git.siax.io/admin/0pt.git",
"manifest_file": "siax.repo.v3.json",
"manifest_operational_state": "running",
"assessed_level": "LIVE_VERIFIED",
"observed_live": "LIVE_VERIFIED",
"gaps": [
"NO_BACKUP_POLICY",
"UNMERGED_LOCAL_BRANCHES",
"UNPUSHED_COMMITS"
],
"ui": true,
"ui_dirs": [
"app",
"public"
],
"prod_domains": [
"0pt.app",
"api.0pt.app"
],
"live": [
{
"host": "0pt.app",
"status": 200,
"class": "public-ok",
"title": "0pt — your data"
},
{
"host": "api.0pt.app",
"status": 200,
"class": "public-ok",
"title": "0pt — your data"
}
],
"deep": {
"tracked_files": 954,
"test_files": 178,
"e2e_files": 0,
"has_playwright": false,
"has_cypress": false,
"api_contracts": [
"contracts/openapi.yaml"
],
"migrations": 0,
"health_refs": 69,
"uses_siax_ui": true,
"has_a11y_tooling": false,
"has_design_conformance": true,
"ui_state_files": [
"app/error.tsx",
"app/not-found.tsx"
],
"todo_count": 10,
"has_runbook_docs": true,
"has_architecture_docs": true,
"has_status_docs": false
},
"backlog": [
{
"priority": "P0",
"task": "Landa grenar",
"next_step": "Kör PR-flöde per gren, verifiera CI och merga; radera grenen lokalt och på origin efter merge."
},
{
"priority": "P1",
"task": "Backup",
"next_step": "Definiera frekvens, retention, mål och kryptering i `siax.backup`."
},
{
"priority": "P1",
"task": "E2E i webbläsare",
"next_step": "Lägg Playwright-flöde för inloggning och kärnflöde mot riktig miljö."
},
{
"priority": "P1",
"task": "Tillgänglighetsgrind",
"next_step": "Aktivera eslint-plugin-jsx-a11y/axe och en blockerande WCAG 2.2-kontroll."
},
{
"priority": "P2",
"task": "Statusdokument",
"next_step": "Lägg `docs/STATUS.md` med nivå, bevis, kända gap och nästa steg."
},
{
"priority": "P2",
"task": "Teknisk skuld",
"next_step": "Prioritera bland 10 TODO/FIXME-markeringar; stäng eller gör dem spårbara som issues."
}
],
"standard": "SIAX Master Definition of Done"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
# 0pt — cosign-signeringskedjan (2026-09-29)
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-6bbd333a22 (sha256:7cbc75bb363c1ee47f8aa86cfbaf996e648e4a0c0c5c5f79a09b87fef93db03b) är cosign-signerad
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-1e5fafce1a (sha256:80237edf1530110650cdfc6da8e821130d9a005cd47d79528d03dbeae2485822) är cosign-signerad
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
+2 -2
View File
@@ -1,4 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELOI331jmeMp5t0XySt0ls4XH0Msr
2k7neTq+QtRAOuLL4b5Nj3xdHLpDBjaX82zI6XXbJRixh0TeWrp0Y0SF3Q==
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgomLeApgQ4ZWeypoU2LfjLIkuBCa
hGwo5+pYW9ae3XMKauDUK17QwMXaSwJ2u9ulefai7+/Fiv3/4Jh8EciMVA==
-----END PUBLIC KEY-----
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.crawl_job",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#siax.acquisition.run",
"authRequired": true,
"status": "planned"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.crawl_job",
"kind": "cli",
"entrypoint": "siax 0pt crawl-job",
"authRequired": true,
"status": "planned"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.crawl_job",
"kind": "job",
"entrypoint": "n0d job:0pt.crawl_job",
"authRequired": true,
"status": "planned"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.crawl_job",
"kind": "mcp",
"entrypoint": "mcp:0pt.crawl_job",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.execute_metric",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0ptExecuteMetric",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.execute_metric",
"kind": "cli",
"entrypoint": "siax 0pt execute-metric",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.load_full_table",
"kind": "job",
"entrypoint": "n0d job:0pt.load_full_table",
"authRequired": true,
"status": "planned"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.render_report",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0ptRenderReport",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.run_data_quality_check",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0ptRunDataQualityCheck",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.run_data_quality_check",
"kind": "cli",
"entrypoint": "siax 0pt run-data-quality-check",
"authRequired": true,
"status": "planned"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.extract_document",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.extract_document",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.get_lineage",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.get_provenance",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.normalize_value",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.normalize_value",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.quality_checks",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.quality.checks",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.quality_list_runs",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.quality.list_runs",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.quality_reports",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.quality.reports",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.quality_run",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.quality.run",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.resolve_entities",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.resolve_entities",
"authRequired": true,
"status": "live"
}
@@ -0,0 +1,8 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.surface.schema.json",
"capabilityId": "0pt.structure.route_parsing",
"kind": "api",
"entrypoint": "contracts/openapi.yaml#0pt.route_parsing",
"authRequired": true,
"status": "live"
}
+16
View File
@@ -0,0 +1,16 @@
-- Trial / grant credits: a server-owned, idempotent balance grant that is not
-- a payment. Extends the existing kind constraint; safe to re-run.
DO $$
DECLARE cname text;
BEGIN
SELECT conname INTO cname
FROM pg_constraint
WHERE conrelid = 'opt_credit_events'::regclass
AND contype = 'c'
AND pg_get_constraintdef(oid) LIKE '%kind%';
IF cname IS NOT NULL THEN
EXECUTE format('ALTER TABLE opt_credit_events DROP CONSTRAINT %I', cname);
END IF;
ALTER TABLE opt_credit_events ADD CONSTRAINT opt_credit_events_kind_check
CHECK (kind IN ('verified_payment', 'grant', 'reserve', 'settle', 'release'));
END $$;
+8
View File
@@ -0,0 +1,8 @@
-- Persist the verified DOC0 receipt on the durable upload attempt so an
-- idempotent retry returns the same 201 instead of "outcome unknown".
-- Additive and re-runnable.
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS document_id text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS version_id text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS receipt_checksum text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS pipeline_stage text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS verified_at timestamptz;
+102
View File
@@ -0,0 +1,102 @@
-- 022_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
--
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
-- must consume these rows through this API/contract, never by writing here.
--
-- Conflict preservation is structural: the unique key includes source_ref and
-- observed_at, so two sources disagreeing about the same predicate are two
-- rows, never an overwrite. `status` records observed/supported/disputed/
-- retracted; reconciliation is a separate, explicit decision.
--
-- Tenant isolation: RLS is FORCEd and keyed on a transaction-local GUC that
-- only trusted server code sets (same pattern as 019_document_credit_rls.sql).
CREATE TABLE IF NOT EXISTS opt_canonical_entities (
entity_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
entity_type TEXT NOT NULL, -- company|person|web_page|product|...
identity_namespace TEXT NOT NULL, -- url|orgnr|lei|gtin|...
identity_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, entity_type, identity_namespace, identity_value)
);
CREATE INDEX IF NOT EXISTS idx_canonical_entities_tenant_type
ON opt_canonical_entities (tenant_id, entity_type);
CREATE TABLE IF NOT EXISTS opt_canonical_facts (
fact_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
entity_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
predicate TEXT NOT NULL, -- e.g. web.title, product.price
value_json JSONB NOT NULL,
value_type TEXT NOT NULL, -- string|number|boolean|date|json
value_unit TEXT,
source_ref TEXT NOT NULL,
source_record_id TEXT,
st0re_artifact_id TEXT,
aud0_evidence_ref TEXT,
run_ref TEXT,
extractor_version TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ,
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
quality_status TEXT NOT NULL DEFAULT 'unvalidated', -- passed|warned|failed|unvalidated
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_fact/v1',
status TEXT NOT NULL DEFAULT 'observed'
CHECK (status IN ('observed','supported','disputed','retracted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, entity_id, predicate, source_ref, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_canonical_facts_entity
ON opt_canonical_facts (tenant_id, entity_id, predicate);
CREATE INDEX IF NOT EXISTS idx_canonical_facts_predicate
ON opt_canonical_facts (tenant_id, predicate, observed_at DESC);
CREATE TABLE IF NOT EXISTS opt_canonical_relations (
relation_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
predicate TEXT NOT NULL, -- LINKS_TO|SOLD_BY|USES_SOFTWARE|...
object_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ref TEXT NOT NULL,
st0re_artifact_id TEXT,
aud0_evidence_ref TEXT,
run_ref TEXT,
extractor_version TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ,
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_relation/v1',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, subject_id, predicate, object_id, source_ref, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_canonical_relations_subject
ON opt_canonical_relations (tenant_id, subject_id, predicate);
CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
ON opt_canonical_relations (tenant_id, object_id, predicate);
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_entities_tenant ON opt_canonical_entities;
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_facts_tenant ON opt_canonical_facts;
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_relations_tenant ON opt_canonical_relations;
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
+19
View File
@@ -0,0 +1,19 @@
-- 023_canonical_grants.sql — app-role privileges for 022 canonical tables.
--
-- Tables created by a manual psql migration are owned by the migrating role
-- (0pt_edge); the running application connects as 0pt_app. Existing 0pt tables
-- carry explicit grants rather than default ACLs, so the canonical tables need
-- the same treatment. RLS still applies to 0pt_app (FORCE is on); this only
-- grants the statement privileges, not row visibility.
--
-- Idempotent and role-guarded: no-op in environments without the app role.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '0pt_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE
ON opt_canonical_entities, opt_canonical_facts, opt_canonical_relations
TO "0pt_app";
END IF;
END
$$;
+79
View File
@@ -0,0 +1,79 @@
/**
* CL0UD workload-identity JWT verification for explicit machine routes.
* Config-driven; fail-closed when a workload token is presented but invalid.
* Tenant/capabilities come from verified claims only.
*/
import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from 'jose';
export type WorkloadPrincipal = {
sub: string;
tenantId: string;
capabilities: string[];
claims: JWTPayload;
};
export class WorkloadVerificationError extends Error {
constructor(message: string) { super(message); this.name = 'WorkloadVerificationError'; }
}
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
let resolverOverride: ((protectedHeader: unknown, token: unknown) => Promise<CryptoKey | Uint8Array>) | null = null;
export function __setWorkloadKeyResolverForTests(fn: typeof resolverOverride) { resolverOverride = fn; }
export function __resetWorkloadJwksCache() { jwksCache.clear(); resolverOverride = null; }
function readEnv(name: string): string | undefined {
const v = process.env[name];
return v && v.trim() ? v.trim() : undefined;
}
export function cl0udWorkloadConfig() {
const issuer = readEnv('CL0UD_WORKLOAD_ISSUER') ?? readEnv('CL0UD_ISSUER');
const jwksUrl = readEnv('CL0UD_WORKLOAD_JWKS_URL');
const audience = readEnv('CL0UD_WORKLOAD_AUDIENCE') ?? '0pt';
const tenantClaim = readEnv('CL0UD_WORKLOAD_TENANT_CLAIM') ?? 'tenant_id';
const capabilitiesClaim = readEnv('CL0UD_WORKLOAD_CAPABILITIES_CLAIM') ?? 'capabilities';
return { issuer, jwksUrl, audience, tenantClaim, capabilitiesClaim };
}
export function isCl0udWorkloadConfigured(): boolean {
const c = cl0udWorkloadConfig();
return Boolean(c.issuer || c.jwksUrl);
}
async function getJwks(c: ReturnType<typeof cl0udWorkloadConfig>) {
if (resolverOverride) return resolverOverride;
const url = c.jwksUrl ?? (c.issuer ? `${c.issuer.replace(/\/$/, '')}/.well-known/jwks.json` : undefined);
if (!url) throw new WorkloadVerificationError('CL0UD workload JWKS URL/issuer not configured');
let jwks = jwksCache.get(url);
if (!jwks) { jwks = createRemoteJWKSet(new URL(url)); jwksCache.set(url, jwks); }
return jwks;
}
function stringArray(v: unknown): string[] {
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string' && x.length > 0);
if (typeof v === 'string' && v.trim()) return v.split(/[\s,]+/).filter(Boolean);
return [];
}
export async function verifyCl0udWorkloadToken(token: string): Promise<WorkloadPrincipal> {
const c = cl0udWorkloadConfig();
if (!isCl0udWorkloadConfigured()) throw new WorkloadVerificationError('CL0UD workload identity not configured');
const jwks = await getJwks(c);
const { payload } = await jwtVerify(token, jwks as any, {
...(c.issuer ? { issuer: c.issuer } : {}),
audience: c.audience,
});
const tenantId = payload[c.tenantClaim];
if (typeof tenantId !== 'string' || !tenantId) throw new WorkloadVerificationError(`missing ${c.tenantClaim} claim`);
const sub = typeof payload.sub === 'string' && payload.sub ? payload.sub : `workload:${tenantId}`;
const capabilities = stringArray(payload[c.capabilitiesClaim] ?? payload.scope);
return { sub, tenantId, capabilities, claims: payload };
}
/** Best-effort check: does this bearer look like a configured CL0UD workload token? */
export function looksLikeCl0udWorkloadToken(token: string): boolean {
const c = cl0udWorkloadConfig();
if (!c.issuer) return false;
try { return decodeJwt(token).iss === c.issuer; } catch { return false; }
}
+81
View File
@@ -0,0 +1,81 @@
/**
* `src/auth/internal-service-tenant.ts` — server-side tenant scope for the
* machine-to-machine principal authenticated by `X-Internal-Token`.
*
* WHY THIS EXISTS (P0, 2026-11-07)
* --------------------------------
* `X-Internal-Token` proves that the caller is the SIAX engine process. It
* does NOT prove which tenant that process may act for. The first attempt at
* fixing the "internal token bypasses the verified-tenant guard" hole copied a
* CLIENT HEADER (`X-0pt-Internal-Tenant`) or fell back to a single
* `INTERNAL_TENANT_ID` env var:
*
* const internalTenant =
* req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID;
*
* That is not an authorization source. Any holder of the shared internal token
* could still name any tenant in a request header, which is exactly the
* cross-tenant write the fix was supposed to stop (tenant_id
* 999999999999999999 returned 201 before).
*
* The correct model mirrors a verified Bearer token: the principal's tenant
* authority is a SERVER-SIDE fact, never a request field.
*
* - `INTERNAL_SERVICE_TENANTS` is a comma-separated allowlist configured on
* the 0pt deployment (env/secret), i.e. "the tenants this engine instance
* is entitled to write for".
* - When the allowlist is empty, the internal principal has NO tenant
* authority: tenant-scoped writes fail closed (403). Routes that do not
* carry a tenant (evidence outbox dispatch, ER candidates, search
* definitions) are unaffected.
* - A single allowlisted tenant is ALSO placed in `x-0pt-verified-tenant`
* by middleware so existing single-tenant checks keep working; two or more
* are carried in `x-0pt-verified-service-tenants` and the tenant guard
* checks membership.
* - Any inbound value for either header is stripped by middleware before
* forwarding, so a client can never forge tenant authority.
*
* No client header is read here. `X-0pt-Internal-Tenant` is deliberately not a
* contract.
*/
/** Env var: comma-separated tenants this internal service principal may assert. */
export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS';
/**
* Request header installed by middleware.ts ONLY (never accepted from a
* client, and stripped from every inbound request). Carries the full
* server-configured service tenant scope; may be the empty string, which means
* "service principal without tenant authority".
*/
export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants';
/**
* Request header installed by middleware.ts ONLY: the CL0UD workload token's
* verified capability list, comma-separated. Stripped from every inbound
* request like the tenant scope.
*/
export const SERVICE_CAPABILITIES_HEADER = 'x-0pt-verified-capabilities';
/** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */
export function parseServiceTenants(value: string | undefined | null): string[] {
if (!value) return [];
const tenants: string[] = [];
for (const part of value.split(',')) {
const tenant = part.trim();
if (tenant && !tenants.includes(tenant)) tenants.push(tenant);
}
return tenants;
}
/** Read the configured service tenant allowlist from the environment. */
export function resolveInternalServiceTenants(
env: { INTERNAL_SERVICE_TENANTS?: string } = process.env,
): string[] {
return parseServiceTenants(env.INTERNAL_SERVICE_TENANTS);
}
/** Read the scope middleware installed on the request (already stripped+set). */
export function readServiceTenantScope(headers: { get(name: string): string | null | undefined }): string[] {
return parseServiceTenants(headers.get(SERVICE_TENANT_SCOPE_HEADER));
}
+25 -8
View File
@@ -50,6 +50,7 @@ import {
type JWTPayload,
type JWTVerifyGetKey,
} from 'jose';
import { normalizeZitadelClaims, ZitadelClaimError } from '@siax/auth-zitadel';
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
@@ -254,13 +255,29 @@ export async function verifyBearer(
throw new BearerVerificationError('token_verification_failed', err);
}
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
if (!sub || sub.length === 0) {
throw new BearerVerificationError('token_missing_sub');
try {
const identity = normalizeZitadelClaims(payload, {
source: 'jwt',
tenantClaim: opts.tenantClaim ?? tenantClaimOverride(),
});
return {
sub: identity.principal.subject,
tenantId: identity.tenant.tenantId,
claims: payload,
};
} catch (err) {
if (err instanceof ZitadelClaimError) {
if (/sub/i.test(err.reason)) {
throw new BearerVerificationError('token_missing_sub');
}
if (/tenant/i.test(err.reason)) {
throw new BearerVerificationError('token_missing_tenant_claim');
}
if (/aud/i.test(err.reason)) {
throw new BearerVerificationError('token_invalid_audience');
}
throw new BearerVerificationError('token_verification_failed', err);
}
throw err;
}
const tenantId = deriveTenantId(payload, opts.tenantClaim ?? tenantClaimOverride());
if (!tenantId) {
throw new BearerVerificationError('token_missing_tenant_claim');
}
return { sub, tenantId, claims: payload };
}
+6 -6
View File
@@ -1,7 +1,7 @@
import type { RepositorySql } from '../repositories/db';
import { withCreditTenant } from './credit-tenant-scope';
export type CreditOperation = 'verified_payment' | 'reserve' | 'settle' | 'release';
export type CreditOperation = 'verified_payment' | 'grant' | 'reserve' | 'settle' | 'release';
export interface CreditCommand {
tenantId: string;
idempotencyKey: string;
@@ -26,8 +26,8 @@ export class PostgresCreditLedger {
/** Read-only balance for the authenticated tenant. Never creates a row. */
async balance(tenantId: string): Promise<CreditBalance> {
if (typeof tenantId !== "string" || !tenantId.trim() || tenantId.length > 256) {
throw new Error("Invalid credit tenant");
if (typeof tenantId !== 'string' || !tenantId.trim() || tenantId.length > 256) {
throw new Error('Invalid credit tenant');
}
return withCreditTenant(this.sql, tenantId, async (tx) => {
const rows = await tx<{ available: string; reserved: string }[]>`
@@ -45,10 +45,10 @@ export class PostgresCreditLedger {
![tenantId, idempotencyKey, reference].every(
(value) => typeof value === 'string' && value.trim().length > 0,
) ||
!['verified_payment', 'reserve', 'settle', 'release'].includes(kind) ||
!['verified_payment', 'grant', 'reserve', 'settle', 'release'].includes(kind) ||
!Number.isSafeInteger(amount) ||
amount < 0 ||
((kind === 'verified_payment' || kind === 'reserve') && amount === 0) ||
((kind === 'verified_payment' || kind === 'grant' || kind === 'reserve') && amount === 0) ||
(kind === 'release' && amount !== 0)
)
throw new Error('Invalid credit command');
@@ -73,7 +73,7 @@ export class PostgresCreditLedger {
return { replay: true, balance: { available, reserved } };
}
if (kind === 'verified_payment') {
if (kind === 'verified_payment' || kind === 'grant') {
available += amount;
} else if (kind === 'reserve') {
if (available < amount) throw new Error('Insufficient available credits');
+159 -39
View File
@@ -24,7 +24,9 @@ export interface Doc0UploadReceipt {
/** The POST may have committed even if its response could not be verified. */
export class Doc0UploadOutcomeUnknownError extends Error {
constructor() {
super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key');
super(
'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key',
);
this.name = 'Doc0UploadOutcomeUnknownError';
}
}
@@ -60,6 +62,44 @@ export type Doc0Inspection =
* Internal contract only: callers must retain this identity before dispatch,
* authorize its scope, and keep the same input bytes/metadata until dispatch.
*/
/**
* One upload identity per operation — not per file.
*
* "Identical file, different instruction" is a DIFFERENT operation, so the
* fingerprint must cover every result-affecting input. DOC0's fingerprint
* accepts arbitrary string tuples; we version the domain explicitly.
*
* Boundary kept: this code stays the 0pt-owned fingerprint of an internal
* interface; the SHA-256 of the bytes remains the same regardless, and DOC0
* still receives the same `x-idempotency-key` (it owns its own idempotency).
*/
export function bindOperationToUploadIdentity(
input: Doc0UploadInput,
operation: {
instruction: string;
fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
modelId: string;
dataClass: string;
format: 'json' | 'csv';
},
): Doc0UploadInput {
const payloadVersion = createHash('sha256')
.update(
JSON.stringify([
'0pt-structure-operation-v1',
operation.instruction.length,
operation.instruction,
operation.fields.map((f) => [f.name, f.type, f.required === true]),
operation.modelId,
operation.dataClass,
operation.format,
]),
)
.digest('hex')
.slice(0, 16);
return { ...input, filename: `${input.filename}#${payloadVersion}` };
}
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
sourceSha256: string;
sourceSizeBytes: number;
@@ -68,13 +108,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
wireMimeType: string;
} {
if (
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType]
.every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
) throw new Error('Invalid DOC0 upload input');
![
input.tenantId,
input.projectId,
input.actorId,
input.requestId,
input.idempotencyKey,
input.filename,
input.mimeType,
].every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) =>
/^[\x21-\x7e]{1,256}$/.test(value),
) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(
input.classification,
) ||
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength === 0
)
throw new Error('Invalid DOC0 upload input');
const wireMimeType = input.mimeType.toLowerCase();
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
throw new Error('Invalid DOC0 upload MIME type');
@@ -83,10 +135,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
const wireFilename = `source.${extension}`;
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
const sourceSizeBytes = input.bytes.byteLength;
const requestFingerprint = createHash('sha256').update(JSON.stringify([
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
])).digest('hex');
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
'doc0-upload-v1',
input.tenantId,
input.projectId,
input.actorId,
sourceSha256,
sourceSizeBytes,
wireFilename,
wireMimeType,
input.filename,
'unknown',
input.classification,
[],
null,
]),
)
.digest('hex');
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
}
@@ -109,7 +176,9 @@ async function readBoundedJson(response: Response): Promise<unknown> {
parts.push(chunk.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
} finally { reader.releaseLock(); }
} finally {
reader.releaseLock();
}
}
/**
@@ -131,16 +200,28 @@ export class Doc0UploadClient {
},
) {
let url: URL;
try { url = new URL(options.baseUrl); }
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
try {
url = new URL(options.baseUrl);
} catch {
throw new Error('A trusted HTTPS DOC0 endpoint is required');
}
if (
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
!options.serviceToken.trim() ||
!Number.isSafeInteger(options.maxBytes) ||
options.maxBytes <= 0 ||
(options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000))
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) ||
options.timeoutMs < 1_000 ||
options.timeoutMs > 300_000))
) {
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
throw new Error(
'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required',
);
}
// Keep the validated values even if the caller later changes its options object.
this.options = { ...options };
@@ -167,7 +248,11 @@ export class Doc0UploadClient {
classification: input.classification,
}),
);
body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename);
body.set(
'file',
new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }),
identity.wireFilename,
);
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
@@ -183,11 +268,16 @@ export class Doc0UploadClient {
body,
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
});
} catch { throw new Doc0UploadOutcomeUnknownError(); }
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
// Even a non-2xx response does not prove the write did not commit.
let decoded: unknown;
try { decoded = await readBoundedJson(response); }
catch { throw new Doc0UploadOutcomeUnknownError(); }
try {
decoded = await readBoundedJson(response);
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
const data = decoded as {
document?: {
documentId?: unknown;
@@ -239,33 +329,63 @@ export class Doc0UploadClient {
* is complete. No automatic retry, settlement or customer route is wired.
*/
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => typeof value === 'string' && value.trim()) ||
input.projectId.length > 256 || input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
if (
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every(
(value) => typeof value === 'string' && value.trim(),
) ||
input.projectId.length > 256 ||
input.idempotencyKey.length > 256 ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) ||
input.sourceSizeBytes <= 0
) {
throw new Error('Invalid DOC0 inspection input');
}
let result: unknown;
try {
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
method: 'POST',
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: {
authorization: `Bearer ${this.options.serviceToken}`,
'content-type': 'application/json',
'x-tenant-id': input.tenantId,
'x-actor-id': input.actorId,
'x-request-id': input.requestId,
'x-idempotency-key': input.idempotencyKey,
},
body: JSON.stringify({
projectId: input.projectId,
sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes,
requestFingerprint: input.requestFingerprint,
}),
});
result = await readBoundedJson(response);
} catch { throw new Doc0InspectionUnavailableError(); }
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
} catch {
throw new Doc0InspectionUnavailableError();
}
if (!result || typeof result !== 'object' || Array.isArray(result))
throw new Doc0InspectionUnavailableError();
const record = result as Record<string, unknown>;
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1) {
if (
typeof record.status === 'string' &&
['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1
) {
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
}
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
typeof record.documentId === 'string' && record.documentId.trim() &&
typeof record.versionId === 'string' && record.versionId.trim()) {
if (
record.status === 'completed_recorded' &&
Object.keys(record).length === 3 &&
typeof record.documentId === 'string' &&
record.documentId.trim() &&
typeof record.versionId === 'string' &&
record.versionId.trim()
) {
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
}
throw new Doc0InspectionUnavailableError();
+40
View File
@@ -0,0 +1,40 @@
/**
* One-time trial credits.
*
* A new tenant can be granted a fixed, server-owned amount so the credit
* ledger (reserve → settle) works in production without a card charge. It is
* idempotent per tenant: the ledger's unique (tenant, idempotency_key) makes
* every repeat a replay, never a second grant. The amount is never read from a
* client; `OPT_TRIAL_CREDITS=0` (default) disables it entirely.
*/
export interface CreditCommandSink {
apply(command: {
tenantId: string;
idempotencyKey: string;
kind: 'grant';
reference: string;
amount: number;
}): Promise<unknown>;
}
export function readTrialCredits(env: NodeJS.ProcessEnv = process.env): number {
const parsed = Number(env.OPT_TRIAL_CREDITS ?? '0');
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 1_000_000) return 0;
return parsed;
}
export async function ensureTrialCredits(
ledger: CreditCommandSink,
tenantId: string,
amount: number,
): Promise<void> {
if (!Number.isSafeInteger(amount) || amount <= 0) return;
if (typeof tenantId !== 'string' || !tenantId.trim()) return;
await ledger.apply({
tenantId,
idempotencyKey: `trial:${tenantId}`,
kind: 'grant',
reference: `trial:${tenantId}`,
amount,
});
}
+85 -8
View File
@@ -13,6 +13,12 @@ export interface UploadAttemptIdentity {
requestFingerprint: string;
}
export type UploadAttemptState = 'prepared' | 'dispatch_started';
export interface StoredUploadReceipt {
documentId: string;
versionId: string;
checksumSha256: string;
pipelineStage: string;
}
interface Row {
source_sha256: string;
source_size_bytes: string;
@@ -21,10 +27,15 @@ interface Row {
}
function validate(input: UploadAttemptIdentity): void {
if (![input.tenantId, input.projectId, input.actorId, input.attemptId].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) || !/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint)
|| !Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
if (
![input.tenantId, input.projectId, input.actorId, input.attemptId].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) ||
input.sourceSizeBytes <= 0
) {
throw new Error('Invalid document upload attempt');
}
}
@@ -40,12 +51,16 @@ function validate(input: UploadAttemptIdentity): void {
export class PostgresUploadAttemptStore {
constructor(private readonly sql: RepositorySql) {}
private async scoped<T>(input: UploadAttemptIdentity, action: (tx: postgres.TransactionSql) => Promise<T>): Promise<T> {
private async scoped<T>(
input: UploadAttemptIdentity,
action: (tx: postgres.TransactionSql) => Promise<T>,
): Promise<T> {
validate(input);
return this.sql.begin(async (tx) => {
const roles = await tx<{ rolsuper: boolean; rolbypassrls: boolean }[]>`
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
if (!roles[0] || roles[0].rolsuper || roles[0].rolbypassrls) throw new Error('Upload attempts require an RLS-enforced database role');
if (!roles[0] || roles[0].rolsuper || roles[0].rolbypassrls)
throw new Error('Upload attempts require an RLS-enforced database role');
await tx`SELECT set_config('app.opt_tenant_id', ${input.tenantId}, true), set_config('app.opt_project_id', ${input.projectId}, true), set_config('app.opt_actor_id', ${input.actorId}, true)`;
return action(tx);
}) as Promise<T>;
@@ -57,11 +72,18 @@ export class PostgresUploadAttemptStore {
INSERT INTO opt_document_upload_attempts (tenant_id,project_id,actor_id,attempt_id,source_sha256,source_size_bytes,request_fingerprint,state)
VALUES (${input.tenantId},${input.projectId},${input.actorId},${input.attemptId},${input.sourceSha256},${input.sourceSizeBytes},${input.requestFingerprint},'prepared')
ON CONFLICT (tenant_id,attempt_id) DO NOTHING RETURNING source_sha256,source_size_bytes,request_fingerprint,state`;
const rows = inserted.length ? inserted : await tx<Row[]>`
const rows = inserted.length
? inserted
: await tx<Row[]>`
SELECT source_sha256,source_size_bytes,request_fingerprint,state FROM opt_document_upload_attempts
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId} AND attempt_id=${input.attemptId}`;
const row = rows[0];
if (!row || row.source_sha256 !== input.sourceSha256 || row.source_size_bytes !== String(input.sourceSizeBytes) || row.request_fingerprint !== input.requestFingerprint) {
if (
!row ||
row.source_sha256 !== input.sourceSha256 ||
row.source_size_bytes !== String(input.sourceSizeBytes) ||
row.request_fingerprint !== input.requestFingerprint
) {
throw new Error('Document upload attempt cannot be reused');
}
return { state: row.state, replay: inserted.length === 0 };
@@ -80,4 +102,59 @@ export class PostgresUploadAttemptStore {
return rows.length === 1;
});
}
/**
* Persist the verified DOC0 receipt so a retry with the same idempotency key
* returns the SAME 201 receipt instead of "outcome unknown". Only the fields
* DOC0 itself returned are stored; storage/extraction are not re-proven here.
*/
async recordReceipt(input: UploadAttemptIdentity, receipt: StoredUploadReceipt): Promise<void> {
if (
![receipt.documentId, receipt.versionId, receipt.pipelineStage].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) ||
!/^[0-9a-f]{64}$/.test(receipt.checksumSha256)
) {
throw new Error('Invalid upload receipt');
}
await this.scoped(input, async (tx) => {
const rows = await tx<{ attempt_id: string }[]>`
UPDATE opt_document_upload_attempts
SET document_id=${receipt.documentId}, version_id=${receipt.versionId},
receipt_checksum=${receipt.checksumSha256}, pipeline_stage=${receipt.pipelineStage},
verified_at=clock_timestamp(), updated_at=clock_timestamp()
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId}
AND attempt_id=${input.attemptId} AND source_sha256=${input.sourceSha256}
AND source_size_bytes=${input.sourceSizeBytes} AND request_fingerprint=${input.requestFingerprint}
RETURNING attempt_id`;
if (rows.length !== 1) throw new Error('Upload attempt receipt could not be recorded');
});
}
async getReceipt(input: UploadAttemptIdentity): Promise<StoredUploadReceipt | undefined> {
return this.scoped(input, async (tx) => {
const rows = await tx<
{
document_id: string | null;
version_id: string | null;
receipt_checksum: string | null;
pipeline_stage: string | null;
}[]
>`
SELECT document_id, version_id, receipt_checksum, pipeline_stage
FROM opt_document_upload_attempts
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId}
AND attempt_id=${input.attemptId} AND source_sha256=${input.sourceSha256}
AND source_size_bytes=${input.sourceSizeBytes} AND request_fingerprint=${input.requestFingerprint}`;
const row = rows[0];
if (!row?.document_id || !row.version_id || !row.receipt_checksum || !row.pipeline_stage)
return undefined;
return {
documentId: row.document_id,
versionId: row.version_id,
checksumSha256: row.receipt_checksum,
pipelineStage: row.pipeline_stage,
};
});
}
}
+40 -12
View File
@@ -6,12 +6,15 @@ import {
type Doc0UploadInput,
type Doc0UploadReceipt,
} from './doc0-upload-client';
import type { UploadAttemptIdentity, UploadAttemptState } from './upload-attempt-store';
import type { StoredUploadReceipt, UploadAttemptIdentity, UploadAttemptState } from './upload-attempt-store';
type Scope = Pick<Doc0UploadInput, 'tenantId' | 'projectId' | 'actorId'>;
type Attempts = {
prepare(input: UploadAttemptIdentity): Promise<{ state: UploadAttemptState; replay: boolean }>;
startDispatch(input: UploadAttemptIdentity): Promise<boolean>;
/** Optional so lightweight tests/implementations keep working. */
recordReceipt?(input: UploadAttemptIdentity, receipt: StoredUploadReceipt): Promise<void>;
getReceipt?(input: UploadAttemptIdentity): Promise<StoredUploadReceipt | undefined>;
};
type Doc0 = {
upload(input: Doc0UploadInput): Promise<Doc0UploadReceipt>;
@@ -28,12 +31,14 @@ export type CoordinatedUpload =
* be deployed and verified before this coordinator can serve customer data.
*/
export class DocumentUploadCoordinator {
constructor(private readonly deps: {
attempts: Attempts;
doc0: Doc0;
authorize: (scope: Scope) => Promise<boolean>;
maxBytes: number;
}) {
constructor(
private readonly deps: {
attempts: Attempts;
doc0: Doc0;
authorize: (scope: Scope) => Promise<boolean>;
maxBytes: number;
},
) {
if (!Number.isSafeInteger(deps.maxBytes) || deps.maxBytes <= 0) throw new Error('Invalid upload limit');
}
@@ -48,27 +53,50 @@ export class DocumentUploadCoordinator {
const scope = { tenantId: snapshot.tenantId, projectId: snapshot.projectId, actorId: snapshot.actorId };
if ((await this.deps.authorize(scope)) !== true) throw new Error('Document upload not authorized');
const attempt: UploadAttemptIdentity = {
...scope, attemptId: snapshot.idempotencyKey, sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes, requestFingerprint: details.requestFingerprint,
...scope,
attemptId: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes,
requestFingerprint: details.requestFingerprint,
};
await this.deps.attempts.prepare(attempt);
// A database error here also stops the operation: the update might have
// committed and must never be followed by an unrecorded network write.
if ((await this.deps.attempts.startDispatch(attempt)) === true) {
try {
return { status: 'receipt_verified', receipt: await this.deps.doc0.upload(snapshot) };
const receipt = await this.deps.doc0.upload(snapshot);
// Persist the verified receipt so retrying with the same idempotency
// key returns this exact 201 instead of "outcome unknown". A journal
// write failure must not turn a real upload into an error response.
try {
await this.deps.attempts.recordReceipt?.(attempt, receipt);
} catch {
/* receipt stays unpersisted; a retry falls back to observation */
}
return { status: 'receipt_verified', receipt };
} catch (error) {
// The client marks every potentially committed POST as unknown. Any
// other error propagates, while the one-way dispatch remains recorded.
if (!(error instanceof Doc0UploadOutcomeUnknownError)) throw error;
}
}
// A retry of an already-dispatched attempt: if the first dispatch returned
// a verified receipt, return it again (same document, same checksum).
try {
const stored = await this.deps.attempts.getReceipt?.(attempt);
if (stored) return { status: 'receipt_verified', receipt: stored };
} catch {
/* fall through to observation */
}
// Observation only. Even not_found does not prove that a write cannot
// still arrive; completed_recorded does not prove storage or extraction.
try {
const observation = await this.deps.doc0.inspect({
...scope, requestId: snapshot.requestId, idempotencyKey: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256, sourceSizeBytes: details.sourceSizeBytes,
...scope,
requestId: snapshot.requestId,
idempotencyKey: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes,
requestFingerprint: details.requestFingerprint,
});
return { status: 'outcome_unknown', observation };
+40 -49
View File
@@ -1,59 +1,35 @@
/**
* src/http/tenant-guard.ts
* ==========================
* P014 fix: every route under `app/api/0pt/**` sits behind `middleware.ts`,
* which performs real Zitadel Bearer verification and — only when a
* principal was actually verified — forwards the result as the
* `x-0pt-verified-tenant` request header (see middleware.ts, src/auth/context.ts).
*
* Independently of that, every one of these route handlers reads `tenant_id`
* from CLIENT-CONTROLLED input (a `?tenant_id=` query param or a JSON body
* field) to decide which tenant's rows to read or write. Until this pass,
* NONE of them cross-checked that client-supplied value against the verified
* header — so a caller holding a valid token for tenant A could read or
* write tenant B's sources, rights, provenance, health, quality rules/runs,
* documents, or lakehouse mappings simply by naming `tenant_id=t_b` in the
* request. `SourceRegistryService.assertSourceTenant` (and friends) only
* checks that the object being read *belongs* to the tenant_id it was asked
* to check against — it has no way to know whether the CALLER is actually
* entitled to assert that tenant_id in the first place. That entitlement
* check belongs at the HTTP boundary, once, here.
*
* This mirrors the exact decision `src/auth/context.ts`'s `resolveAuthContext`
* already makes for the enforced-Bearer path ("a disagreeing X-Tenant-Id
* header is a 403"), applied uniformly to every 0pt capability route that
* takes tenant_id as a body/query parameter rather than relying on
* middleware to inject ctx.tenantId directly (most of these routes predate
* that plumbing and read tenant_id straight from the request; fixing that
* more deeply is a larger refactor than this pass — this closes the actual
* authz hole without changing every route's request contract).
*/
import { NextResponse, type NextRequest } from "next/server";
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
/**
* Returns a 403 NextResponse when `tenantId` (whatever the route read it
* from — query string or body) disagrees with the tenant middleware.ts
* already verified for this request. Returns `null` when the caller may
* proceed — either because the tenant matches, or because no verified
* tenant is present on the request at all.
*
* The header is absent only in the explicit local-development escape hatch
* (`AUTH_ENFORCE=false` with no bearer token presented) — `assertAuthConfigSafe()`
* refuses to boot that way in production, so an absent header here can only
* happen in a mode that already accepts fully unauthenticated requests.
* Rejecting in that mode would be strictly more restrictive than the
* existing dev escape hatch intends, so this deliberately no-ops rather than
* invents a tenant to compare against.
*/
export function assertTenantMatchesVerified(
request: NextRequest,
tenantId: string | null | undefined,
): NextResponse | null {
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
// Internal service principal: tenant authority is the SERVER-CONFIGURED
// allowlist middleware installed (never a client header). The scope header is
// present even when the allowlist is empty, which means "no tenant
// authority" -> fail closed. Routes that do not take a tenant never call
// this guard, so evidence dispatch / ER / search definitions keep working.
if (request.headers.get("x-0pt-verified-service-tenants") !== null) {
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
const scope = readServiceTenantScope(request.headers);
if (!scope.includes(tenantId)) {
return NextResponse.json(
{
error: "OPT_ACCESS_DENIED",
message: "tenant_id is outside the internal service principal's configured tenant scope",
details: { requested_tenant: tenantId, service_tenant_scope: scope },
},
{ status: 403 },
);
}
return null;
}
if (!verified) return null;
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
if (!tenantId) return null;
if (tenantId !== verified) {
return NextResponse.json(
{
@@ -67,7 +43,6 @@ export function assertTenantMatchesVerified(
return null;
}
/** Bind document operations to middleware's verified subject; never accept a conflicting body actor. */
export function resolveDocumentActor(
request: NextRequest,
requestedActor: string | undefined,
@@ -81,7 +56,23 @@ export function resolveDocumentActor(
),
};
}
// Absence is the existing unauthenticated development path. In enforced
// mode middleware always installs the verified subject before this route.
return { actorId: verified || requestedActor };
}
export function assertWorkloadCapabilities(
request: NextRequest,
required: readonly string[],
): NextResponse | null {
if (!required.length) return null;
const raw = request.headers.get('x-0pt-verified-capabilities');
if (raw === null) return null; // not a workload-capability-scoped principal
const caps = raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
const missing = required.filter((r) => !caps.includes(r) && !caps.includes('*'));
if (missing.length) {
return NextResponse.json(
{ error: 'OPT_ACCESS_DENIED', message: 'missing required workload capability', details: { missing } },
{ status: 403 },
);
}
return null;
}
@@ -0,0 +1,232 @@
/**
* src/repositories/canonical-fact-repository.ts
* =============================================
* Storage seam for 0pt canonical entities/facts/relations (sql/022).
* Same split as source-repository.ts: one interface, an in-memory
* implementation (dev/test) and a Postgres implementation (production),
* chosen once via resolveStoreMode.
*
* Tenant isolation is enforced by the service + route AND by RLS in Postgres
* (transaction-local GUC `app.opt_canonical_tenant_id`, same pattern as
* 019_document_credit_rls.sql). The in-memory backend scopes by tenant_id in
* every read/write.
*/
import { randomUUID } from "crypto";
import { resolveStoreMode } from "../platform/dependency-mode";
import type {
CanonicalEntity,
CanonicalEntityInput,
CanonicalFact,
CanonicalFactInput,
CanonicalQuery,
CanonicalRelationInput,
} from "../types/canonical-facts";
import { getRepositorySql, repositoryDatabaseUrl, type RepositorySql } from "./db";
export interface CanonicalFactRepository {
upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]>;
insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }>;
queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]>;
}
/** PostgreSQL timestamptz arrives as Date; the API contract is RFC3339/ISO-8601. */
function iso(value: unknown): string {
return value instanceof Date ? value.toISOString() : String(value);
}
export function entityKey(e: CanonicalEntityInput): string {
return `${e.entity_type}\u0000${e.identity_namespace}\u0000${e.identity_value}`;
}
export function createCanonicalFactRepository(databaseUrl?: string): CanonicalFactRepository {
const mode = resolveStoreMode("0pt.canonical_facts", databaseUrl ?? repositoryDatabaseUrl());
if (mode.mode === "postgres") {
return new PostgresCanonicalFactRepository(getRepositorySql(mode.url));
}
return new InMemoryCanonicalFactRepository();
}
// ── in-memory (dev/test) ────────────────────────────────────────────────────
class InMemoryCanonicalFactRepository implements CanonicalFactRepository {
private entities = new Map<string, CanonicalEntity>();
private facts = new Map<string, CanonicalFact>();
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
const now = new Date().toISOString();
return inputs.map((input) => {
const key = `${tenantId}\u0000${entityKey(input)}`;
const existing = this.entities.get(key);
if (existing) return existing;
const entity: CanonicalEntity = {
...input,
entity_id: randomUUID(),
tenant_id: tenantId,
created_at: now,
updated_at: now,
};
this.entities.set(key, entity);
return entity;
});
}
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
let inserted = 0;
let duplicates = 0;
for (const input of inputs) {
const entityId = entityIds.get(entityKey(input.entity));
if (!entityId) continue;
const key = `${tenantId}\u0000${entityId}\u0000${input.predicate}\u0000${input.source_ref}\u0000${input.observed_at}`;
if (this.facts.has(key)) {
duplicates += 1;
continue;
}
this.facts.set(key, {
...input,
fact_id: randomUUID(),
tenant_id: tenantId,
entity_id: entityId,
created_at: new Date().toISOString(),
});
inserted += 1;
}
return { inserted, duplicates };
}
async insertRelations(): Promise<{ inserted: number; duplicates: number }> {
// Relations are not persisted in the in-memory backend beyond validation;
// tests assert the write path through facts + counts.
return { inserted: 0, duplicates: 0 };
}
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
const limit = query.limit ?? 100;
return [...this.facts.values()]
.filter((f) => f.tenant_id === query.tenant_id)
.filter((f) => (query.entity_id ? f.entity_id === query.entity_id : true))
.filter((f) => (query.predicate ? f.predicate === query.predicate : true))
.slice(0, limit);
}
}
// ── postgres (production) ───────────────────────────────────────────────────
class PostgresCanonicalFactRepository implements CanonicalFactRepository {
constructor(private readonly sql: RepositorySql) {}
private async tx<T>(tenantId: string, fn: (tx: RepositorySql) => Promise<T>): Promise<T> {
return this.sql.begin(async (tx) => {
await tx`SELECT set_config('app.opt_canonical_tenant_id', ${tenantId}, true)`;
return fn(tx as unknown as RepositorySql);
}) as Promise<T>;
}
async upsertEntities(tenantId: string, inputs: CanonicalEntityInput[]): Promise<CanonicalEntity[]> {
if (inputs.length === 0) return [];
return this.tx(tenantId, async (tx) => {
const out: CanonicalEntity[] = [];
for (const input of inputs) {
const rows = await tx`
INSERT INTO opt_canonical_entities (entity_id, tenant_id, entity_type, identity_namespace, identity_value)
VALUES (${randomUUID()}, ${tenantId}, ${input.entity_type}, ${input.identity_namespace}, ${input.identity_value})
ON CONFLICT (tenant_id, entity_type, identity_namespace, identity_value)
DO UPDATE SET updated_at = now()
RETURNING entity_id, tenant_id, entity_type, identity_namespace, identity_value, created_at, updated_at`;
const r = rows[0];
out.push({
entity_id: r.entity_id, tenant_id: r.tenant_id, entity_type: r.entity_type,
identity_namespace: r.identity_namespace, identity_value: r.identity_value,
created_at: String(r.created_at), updated_at: String(r.updated_at),
});
}
return out;
});
}
async insertFacts(tenantId: string, inputs: CanonicalFactInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
return this.tx(tenantId, async (tx) => {
let inserted = 0;
for (const input of inputs) {
const entityId = entityIds.get(entityKey(input.entity));
if (!entityId) continue;
const rows = await tx`
INSERT INTO opt_canonical_facts (
fact_id, tenant_id, entity_id, predicate, value_json, value_type, value_unit,
source_ref, source_record_id, st0re_artifact_id, aud0_evidence_ref, run_ref,
extractor_version, observed_at, valid_from, valid_to, confidence, quality_status, status
) VALUES (
${randomUUID()}, ${tenantId}, ${entityId}, ${input.predicate}, ${JSON.stringify(input.value)}::jsonb,
${input.value_type ?? "json"}, ${input.value_unit ?? null}, ${input.source_ref},
${input.source_record_id ?? null}, ${input.st0re_artifact_id ?? null},
${input.aud0_evidence_ref ?? null}, ${input.run_ref ?? null}, ${input.extractor_version},
${input.observed_at}, ${input.valid_from ?? null}, ${input.valid_to ?? null},
${input.confidence ?? null}, ${input.quality_status ?? "unvalidated"}, ${input.status ?? "observed"}
)
ON CONFLICT (tenant_id, entity_id, predicate, source_ref, observed_at) DO NOTHING
RETURNING fact_id`;
if (rows.length > 0) inserted += 1;
}
return { inserted, duplicates: inputs.length - inserted };
});
}
async insertRelations(tenantId: string, inputs: CanonicalRelationInput[], entityIds: Map<string, string>): Promise<{ inserted: number; duplicates: number }> {
if (inputs.length === 0) return { inserted: 0, duplicates: 0 };
return this.tx(tenantId, async (tx) => {
let inserted = 0;
for (const input of inputs) {
const subjectId = entityIds.get(entityKey(input.subject));
const objectId = entityIds.get(entityKey(input.object));
if (!subjectId || !objectId) continue;
const rows = await tx`
INSERT INTO opt_canonical_relations (
relation_id, tenant_id, subject_id, predicate, object_id, attributes_json,
source_ref, st0re_artifact_id, aud0_evidence_ref, run_ref, extractor_version,
observed_at, valid_from, valid_to, confidence
) VALUES (
${randomUUID()}, ${tenantId}, ${subjectId}, ${input.predicate}, ${objectId},
${JSON.stringify(input.attributes ?? {})}::jsonb, ${input.source_ref},
${input.st0re_artifact_id ?? null}, ${input.aud0_evidence_ref ?? null},
${input.run_ref ?? null}, ${input.extractor_version}, ${input.observed_at},
${input.valid_from ?? null}, ${input.valid_to ?? null}, ${input.confidence ?? null}
)
ON CONFLICT (tenant_id, subject_id, predicate, object_id, source_ref, observed_at) DO NOTHING
RETURNING relation_id`;
if (rows.length > 0) inserted += 1;
}
return { inserted, duplicates: inputs.length - inserted };
});
}
async queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]> {
const limit = query.limit ?? 100;
return this.tx(query.tenant_id, async (tx) => {
const rows = await tx`
SELECT f.*, e.entity_type, e.identity_namespace, e.identity_value
FROM opt_canonical_facts f
JOIN opt_canonical_entities e ON e.entity_id = f.entity_id
WHERE f.tenant_id = ${query.tenant_id}
${query.entity_id ? tx`AND f.entity_id = ${query.entity_id}` : tx``}
${query.predicate ? tx`AND f.predicate = ${query.predicate}` : tx``}
${query.entity_type ? tx`AND e.entity_type = ${query.entity_type}` : tx``}
${query.identity_namespace ? tx`AND e.identity_namespace = ${query.identity_namespace}` : tx``}
${query.identity_value ? tx`AND e.identity_value = ${query.identity_value}` : tx``}
ORDER BY f.observed_at DESC
LIMIT ${limit}`;
return rows.map((r) => ({
fact_id: r.fact_id, tenant_id: r.tenant_id, entity_id: r.entity_id,
entity: { entity_type: r.entity_type, identity_namespace: r.identity_namespace, identity_value: r.identity_value },
predicate: r.predicate, value: r.value_json, value_type: r.value_type,
value_unit: r.value_unit ?? undefined, source_ref: r.source_ref,
source_record_id: r.source_record_id ?? undefined,
st0re_artifact_id: r.st0re_artifact_id ?? undefined,
aud0_evidence_ref: r.aud0_evidence_ref ?? undefined, run_ref: r.run_ref ?? undefined,
extractor_version: r.extractor_version, observed_at: iso(r.observed_at),
valid_from: r.valid_from ? iso(r.valid_from) : undefined,
valid_to: r.valid_to ? iso(r.valid_to) : undefined,
confidence: r.confidence === null ? undefined : Number(r.confidence),
quality_status: r.quality_status, status: r.status, created_at: iso(r.created_at),
})) as CanonicalFact[];
});
}
}
+4
View File
@@ -0,0 +1,4 @@
/** Shared singleton for the canonical-fact routes (same pattern as source-registry.ts). */
import { CanonicalFactService } from "./canonical-fact-service";
export const canonicalFactService = new CanonicalFactService();
+165
View File
@@ -0,0 +1,165 @@
/**
* src/services/canonical-fact-service.ts
* ======================================
* 0pt's canonical structured-observation write/read surface (sql/022).
*
* Ownership boundary: 0pt stores normalized OBSERVED facts (what a source
* said, when, from which artifact). L0RE owns epistemic propositions and
* projections and must consume these rows through this API. Nothing here
* overwrites an earlier observation: the unique key includes source_ref +
* observed_at, so conflicting sources are preserved as separate rows.
*
* Idempotency: identical re-writes hit ON CONFLICT DO NOTHING and are
* reported as `duplicates`, never duplicated.
*/
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
import { SiaxError } from "../platform/errors";
import {
createCanonicalFactRepository,
entityKey,
type CanonicalFactRepository,
} from "../repositories/canonical-fact-repository";
import type {
CanonicalEntity,
CanonicalEntityInput,
CanonicalFact,
CanonicalFactInput,
CanonicalQuery,
CanonicalRelationInput,
CanonicalWriteRequest,
CanonicalWriteResult,
FactValueType,
} from "../types/canonical-facts";
const FACT_VALUE_TYPES: FactValueType[] = ["string", "number", "boolean", "date", "json"];
function inferValueType(value: unknown): FactValueType {
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
if (typeof value === "boolean") return "boolean";
return "json";
}
function assertEntity(entity: CanonicalEntityInput, label: string): void {
if (!entity || !entity.entity_type || !entity.identity_namespace || !entity.identity_value) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `${label} requires entity_type, identity_namespace and identity_value`);
}
}
function assertFact(fact: CanonicalFactInput, index: number): void {
assertEntity(fact.entity, `facts[${index}].entity`);
if (!fact.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].predicate is required`);
if (!fact.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].source_ref is required`);
if (!fact.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].extractor_version is required`);
if (!fact.observed_at || Number.isNaN(Date.parse(fact.observed_at))) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].observed_at must be an ISO timestamp`);
}
if (fact.value === undefined) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value is required`);
if (fact.value_type && !FACT_VALUE_TYPES.includes(fact.value_type)) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].value_type is invalid`);
}
if (fact.confidence !== undefined && (fact.confidence < 0 || fact.confidence > 1)) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", `facts[${index}].confidence must be between 0 and 1`);
}
}
function assertRelation(relation: CanonicalRelationInput, index: number): void {
assertEntity(relation.subject, `relations[${index}].subject`);
assertEntity(relation.object, `relations[${index}].object`);
if (!relation.predicate) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].predicate is required`);
if (!relation.source_ref) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].source_ref is required`);
if (!relation.extractor_version) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].extractor_version is required`);
if (!relation.observed_at || Number.isNaN(Date.parse(relation.observed_at))) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_relation", `relations[${index}].observed_at must be an ISO timestamp`);
}
}
export interface CanonicalFactServiceOptions {
repository?: CanonicalFactRepository;
aud0BaseUrl?: string;
databaseUrl?: string;
}
export class CanonicalFactService {
private readonly repo: CanonicalFactRepository;
private readonly aud0: Aud0Client;
constructor(opts?: CanonicalFactServiceOptions) {
this.repo = opts?.repository ?? createCanonicalFactRepository(opts?.databaseUrl);
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
}
async write(input: CanonicalWriteRequest): Promise<CanonicalWriteResult> {
if (!input.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
const facts = input.facts ?? [];
const relations = input.relations ?? [];
const explicit = input.entities ?? [];
if (facts.length === 0 && relations.length === 0 && explicit.length === 0) {
throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "at least one entity, fact or relation is required");
}
facts.forEach(assertFact);
relations.forEach(assertRelation);
// Unique entity inputs from explicit + facts + relations.
const unique = new Map<string, CanonicalEntityInput>();
for (const e of [...explicit, ...facts.map((f) => f.entity), ...relations.flatMap((r) => [r.subject, r.object])]) {
assertEntity(e, "entity");
unique.set(entityKey(e), e);
}
const entities: CanonicalEntity[] = await this.repo.upsertEntities(input.tenant_id, [...unique.values()]);
const entityIds = new Map(entities.map((e) => [entityKey(e), e.entity_id]));
const normalizedFacts = facts.map((f) => ({ ...f, value_type: f.value_type ?? inferValueType(f.value) }));
const factResult = await this.repo.insertFacts(input.tenant_id, normalizedFacts, entityIds);
const relationResult = await this.repo.insertRelations(input.tenant_id, relations, entityIds);
// AUD0: counts and provenance refs only, never fact values (PII discipline).
try {
await this.aud0.ingestEvent({
event_id: `evt_0pt_canonical_facts_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
event_type: "0pt.canonical_facts_written",
tenant_id: input.tenant_id,
project_id: input.project_id ?? input.tenant_id,
actor_id: "0pt",
app_id: "0pt",
capability_id: "0pt.canonical_fact_write",
resource_type: "canonical_facts",
resource_id: input.tenant_id,
risk_level: "L2",
created_at: new Date().toISOString(),
payload: {
entities: entities.length,
facts_inserted: factResult.inserted,
facts_duplicates: factResult.duplicates,
relations_inserted: relationResult.inserted,
relations_duplicates: relationResult.duplicates,
source_refs: [...new Set([...facts.map((f) => f.source_ref), ...relations.map((r) => r.source_ref)])],
run_refs: [...new Set([...facts.map((f) => f.run_ref), ...relations.map((r) => r.run_ref)].filter(Boolean))],
},
});
} catch {
// AUD0 outage must not lose the canonical write; the caller sees counts.
}
return {
entities,
facts: normalizedFacts.map((f) => ({
fact_id: "",
entity_id: entityIds.get(entityKey(f.entity)) ?? "",
predicate: f.predicate,
})),
relations: relations.map((r) => ({
relation_id: "",
subject_id: entityIds.get(entityKey(r.subject)) ?? "",
predicate: r.predicate,
object_id: entityIds.get(entityKey(r.object)) ?? "",
})),
duplicates: factResult.duplicates + relationResult.duplicates,
};
}
async query(query: CanonicalQuery): Promise<CanonicalFact[]> {
if (!query.tenant_id) throw new SiaxError("INVALID_REQUEST", "0pt.canonical_fact", "tenant_id is required");
return this.repo.queryFacts({ ...query, limit: Math.min(query.limit ?? 100, 500) });
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* src/types/canonical-facts.ts — 0pt canonical structured observations.
*
* 0pt owns OBSERVED facts (source said X at time T, from artifact A). L0RE
* owns epistemic propositions/assertions and projections derived from these.
* This contract is the single write path into the canonical observation store;
* downstream products consume it through the same API instead of their own DB.
*/
export interface CanonicalEntityInput {
entity_type: string;
identity_namespace: string;
identity_value: string;
}
export interface CanonicalEntity extends CanonicalEntityInput {
entity_id: string;
tenant_id: string;
created_at: string;
updated_at: string;
}
export type FactValueType = "string" | "number" | "boolean" | "date" | "json";
export type QualityStatus = "passed" | "warned" | "failed" | "unvalidated";
export type FactStatus = "observed" | "supported" | "disputed" | "retracted";
export interface CanonicalFactInput {
entity: CanonicalEntityInput;
predicate: string;
value: unknown;
value_type?: FactValueType;
value_unit?: string;
source_ref: string;
source_record_id?: string;
st0re_artifact_id?: string;
aud0_evidence_ref?: string;
run_ref?: string;
extractor_version: string;
observed_at: string;
valid_from?: string;
valid_to?: string;
confidence?: number;
quality_status?: QualityStatus;
status?: FactStatus;
}
export interface CanonicalRelationInput {
subject: CanonicalEntityInput;
predicate: string;
object: CanonicalEntityInput;
attributes?: Record<string, unknown>;
source_ref: string;
st0re_artifact_id?: string;
aud0_evidence_ref?: string;
run_ref?: string;
extractor_version: string;
observed_at: string;
valid_from?: string;
valid_to?: string;
confidence?: number;
}
export interface CanonicalFact extends CanonicalFactInput {
fact_id: string;
tenant_id: string;
entity_id: string;
created_at: string;
}
export interface CanonicalRelation extends CanonicalRelationInput {
relation_id: string;
tenant_id: string;
subject_id: string;
object_id: string;
created_at: string;
}
export interface CanonicalWriteRequest {
tenant_id: string;
project_id?: string;
entities?: CanonicalEntityInput[];
facts?: CanonicalFactInput[];
relations?: CanonicalRelationInput[];
}
export interface CanonicalWriteResult {
entities: CanonicalEntity[];
facts: { fact_id: string; entity_id: string; predicate: string }[];
relations: { relation_id: string; subject_id: string; predicate: string; object_id: string }[];
/** Identical re-inserts skipped by the unique key (idempotent replay). */
duplicates: number;
}
export interface CanonicalQuery {
tenant_id: string;
entity_id?: string;
entity_type?: string;
identity_namespace?: string;
identity_value?: string;
predicate?: string;
limit?: number;
}
+60
View File
@@ -263,3 +263,63 @@ export const activateSourceSchema = z.object({
tenant_id: z.string().min(1),
actor_id: z.string().min(1).optional(),
});
/** POST /api/0pt/canonical/facts — canonical structured observation write (sql/022). */
const canonicalEntityInputSchema = z.object({
entity_type: z.string().min(1),
identity_namespace: z.string().min(1),
identity_value: z.string().min(1),
});
const canonicalValueSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(z.unknown()),
jsonObject,
]);
export const canonicalFactInputSchema = z.object({
entity: canonicalEntityInputSchema,
predicate: z.string().min(1),
value: canonicalValueSchema,
value_type: z.enum(['string', 'number', 'boolean', 'date', 'json']).optional(),
value_unit: z.string().min(1).optional(),
source_ref: z.string().min(1),
source_record_id: z.string().min(1).optional(),
st0re_artifact_id: z.string().min(1).optional(),
aud0_evidence_ref: z.string().min(1).optional(),
run_ref: z.string().min(1).optional(),
extractor_version: z.string().min(1),
observed_at: z.string().min(1),
valid_from: z.string().min(1).optional(),
valid_to: z.string().min(1).optional(),
confidence: z.number().min(0).max(1).optional(),
quality_status: z.enum(['passed', 'warned', 'failed', 'unvalidated']).optional(),
status: z.enum(['observed', 'supported', 'disputed', 'retracted']).optional(),
});
export const canonicalRelationInputSchema = z.object({
subject: canonicalEntityInputSchema,
predicate: z.string().min(1),
object: canonicalEntityInputSchema,
attributes: jsonObject.optional(),
source_ref: z.string().min(1),
st0re_artifact_id: z.string().min(1).optional(),
aud0_evidence_ref: z.string().min(1).optional(),
run_ref: z.string().min(1).optional(),
extractor_version: z.string().min(1),
observed_at: z.string().min(1),
valid_from: z.string().min(1).optional(),
valid_to: z.string().min(1).optional(),
confidence: z.number().min(0).max(1).optional(),
});
export const canonicalWriteSchema = z.object({
tenant_id: z.string().min(1),
project_id: z.string().min(1).optional(),
entities: z.array(canonicalEntityInputSchema).optional(),
facts: z.array(canonicalFactInputSchema).optional(),
relations: z.array(canonicalRelationInputSchema).optional(),
});
@@ -0,0 +1,262 @@
/**
* P0 regression (2026-11-07): tenant authority for the machine-to-machine
* (`X-Internal-Token`) principal is SERVER CONFIGURATION, never a client
* header.
*
* The first attempt at this fix read `X-0pt-Internal-Tenant` (or a single
* `INTERNAL_TENANT_ID`) and copied it into `x-0pt-verified-tenant`. Any holder
* of the shared internal token could then still name any tenant, so the
* original cross-tenant write (tenant_id 999999999999999999 -> 201) remained
* possible through a different header. These tests pin the corrected model:
*
* - `INTERNAL_SERVICE_TENANTS` (server env) is the only source of tenant
* authority for the service principal.
* - No configured scope => tenant-scoped writes are denied (fail closed).
* - Client-supplied `x-0pt-internal-tenant`, `x-0pt-verified-tenant` and
* `x-0pt-verified-service-tenants` are ignored and stripped.
* - Bearer/session principals are unchanged.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { middleware } from '../../middleware';
import {
parseServiceTenants,
resolveInternalServiceTenants,
SERVICE_TENANT_SCOPE_HEADER,
} from '@/src/auth/internal-service-tenant';
import { assertTenantMatchesVerified } from '@/src/http/tenant-guard';
import { POST as canonicalPOST } from '@/app/api/0pt/canonical/facts/route';
import { POST as bridgePOST } from '@/app/api/0pt/quality/bridge-run/route';
import { POST as evidenceDispatchPOST } from '@/app/api/0pt/internal/evidence-outbox/dispatch/route';
const INTERNAL_TOKEN = 'engine-shared-secret';
const SCOPED_ROUTE = '/api/0pt/canonical/facts';
function req(path: string, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(new URL(path, 'http://localhost'), { headers });
}
/** Headers middleware forwarded to the route handler (Next stores them here). */
function forwarded(res: { headers: { get(name: string): string | null } }, name: string): string | null {
return res.headers.get(`x-middleware-request-${name.toLowerCase()}`);
}
function guardRequest(headers: Record<string, string>): NextRequest {
return new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
});
}
describe('parseServiceTenants / resolveInternalServiceTenants', () => {
it('splits, trims and de-duplicates the allowlist', () => {
expect(parseServiceTenants(' t_a , t_b ,, t_a ')).toEqual(['t_a', 't_b']);
});
it('an unset/empty allowlist means no tenant authority', () => {
expect(parseServiceTenants(undefined)).toEqual([]);
expect(parseServiceTenants('')).toEqual([]);
expect(resolveInternalServiceTenants({})).toEqual([]);
});
});
describe('middleware: internal-token tenant authority comes from server config', () => {
const saved = {
auth: process.env.AUTH_ENFORCE,
token: process.env.INTERNAL_TOKEN,
tenants: process.env.INTERNAL_SERVICE_TENANTS,
node: process.env.NODE_ENV,
};
beforeEach(() => {
delete process.env.AUTH_ENFORCE; // default posture: enforced
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
delete process.env.INTERNAL_SERVICE_TENANTS;
});
afterEach(() => {
const values: Record<string, string | undefined> = {
AUTH_ENFORCE: saved.auth,
INTERNAL_TOKEN: saved.token,
INTERNAL_SERVICE_TENANTS: saved.tenants,
NODE_ENV: saved.node,
};
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('single allowlisted tenant is installed as the verified tenant', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('REFUSES an ambiguous multi-tenant allowlist (no workload-identity binding yet)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain('ambiguous');
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBeNull();
});
it('a single allowlist entry is still forwarded as the service scope', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('');
});
it('IGNORES a client-supplied X-0pt-Internal-Tenant (not an authority source)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-internal-tenant': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
});
it('STRIPS a forged x-0pt-verified-tenant and forged scope header', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-verified-tenant': 't_victim',
'x-0pt-verified-sub': 'user_victim',
'x-0pt-verified-service-tenants': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, 'x-0pt-verified-sub')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('strips forged verified headers on the unauthenticated development path too', async () => {
process.env.AUTH_ENFORCE = 'false';
const res = await middleware(req('/api/0pt/documents/capture', { 'x-0pt-verified-tenant': 't_victim' }));
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
});
it('an invalid internal token still falls through to 401', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': 'wrong' }));
expect(res.status).toBe(401);
});
});
describe('assertTenantMatchesVerified: service scope is enforced fail-closed', () => {
it('no service scope header behaves exactly as before (Bearer verified tenant)', () => {
const same = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_a',
);
expect(same).toBeNull();
const cross = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_victim',
);
expect(cross?.status).toBe(403);
});
it('allows a tenant inside the configured service scope', () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a,t_b' }),
't_b',
);
expect(res).toBeNull();
});
it('DENIES a tenant outside the configured service scope (403)', async () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }),
't_victim',
);
expect(res?.status).toBe(403);
const body = await res!.json();
expect(body.error).toBe('OPT_ACCESS_DENIED');
expect(body.details.service_tenant_scope).toEqual(['t_a']);
});
it('DENIES every tenant when the configured scope is empty (fail closed, even if a verified tenant was also set)', () => {
const res = assertTenantMatchesVerified(
guardRequest({
[SERVICE_TENANT_SCOPE_HEADER]: '',
'x-0pt-verified-tenant': '999999999999999999',
}),
'999999999999999999',
);
expect(res?.status).toBe(403);
});
});
describe('route handlers: internal service cannot write a foreign tenant', () => {
it('canonical facts: cross-tenant write is 403 before the service runs', async () => {
const res = await canonicalPOST(
new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
entities: [],
facts: [],
relations: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('quality bridge-run: cross-tenant run is 403 before rules are provisioned', async () => {
const res = await bridgePOST(
new NextRequest(new URL('/api/0pt/quality/bridge-run', 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
project_id: 'p1',
dataset_id: 'ds_1',
rows: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('evidence-outbox dispatch needs no tenant and is unchanged by the scope', async () => {
// No DB in this test env: the route authenticates first, then reports a
// dependency error -- anything except 401/403 proves the scope did not
// turn a tenant-less machine route into a denial.
const previous = process.env.INTERNAL_TOKEN;
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
try {
const res = await evidenceDispatchPOST(
new NextRequest(new URL('/api/0pt/internal/evidence-outbox/dispatch', 'http://localhost'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-internal-token': INTERNAL_TOKEN,
[SERVICE_TENANT_SCOPE_HEADER]: '',
},
body: JSON.stringify({ limit: 1 }),
}),
);
expect([401, 403]).not.toContain(res.status);
} finally {
if (previous === undefined) delete process.env.INTERNAL_TOKEN;
else process.env.INTERNAL_TOKEN = previous;
}
});
});
@@ -45,6 +45,13 @@ describe('middleware.ts: internal-token path for /api/0pt/quality/bridge-run', (
expect(res.status).toBe(200);
});
it('a valid X-Internal-Token lets the acquisition engine write canonical facts', async () => {
const res = await middleware(
req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }),
);
expect(res.status).toBe(200);
});
it('an invalid X-Internal-Token still falls through to the ordinary Bearer requirement (401, not a bypass)', async () => {
const res = await middleware(req('/api/0pt/quality/bridge-run', { 'x-internal-token': 'wrong-secret' }));
expect(res.status).toBe(401);
@@ -0,0 +1,151 @@
/**
* Dual-accept workload identity: the machine routes accept EITHER the legacy
* shared `X-Internal-Token` (server-configured single-tenant scope) OR a
* verified CL0UD workload JWT (tenant + capabilities from verified claims
* only). These tests pin the security properties that make that safe:
*
* - a client-supplied `x-0pt-verified-*` header can never authorize
* anything (it is stripped before the handler sees it);
* - an ambiguous legacy scope is refused instead of silently widening;
* - a workload token for tenant A cannot write tenant B's facts;
* - a workload token without the required capability is denied.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { SignJWT, generateKeyPair } from 'jose';
import { middleware } from '../../middleware';
import { assertTenantMatchesVerified, assertWorkloadCapabilities } from '../../src/http/tenant-guard';
import { __resetWorkloadJwksCache, __setWorkloadKeyResolverForTests } from '../../src/auth/cl0ud-workload';
import { SERVICE_CAPABILITIES_HEADER, SERVICE_TENANT_SCOPE_HEADER } from '../../src/auth/internal-service-tenant';
const ISSUER = 'https://cl0ud.siax.io';
const TENANT_A = '379976258701823235';
const TENANT_B = '999999999999999999';
function req(path: string, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(new URL(path, 'http://localhost'), { headers });
}
let privateKey: CryptoKey;
let publicKey: CryptoKey;
beforeEach(async () => {
const pair = await generateKeyPair('RS256');
privateKey = pair.privateKey as CryptoKey;
publicKey = pair.publicKey as CryptoKey;
__setWorkloadKeyResolverForTests(async () => publicKey);
});
afterEach(() => {
__resetWorkloadJwksCache();
for (const key of ['INTERNAL_TOKEN', 'INTERNAL_SERVICE_TENANTS', 'CL0UD_WORKLOAD_ISSUER', 'CL0UD_WORKLOAD_AUDIENCE', 'AUTH_ENFORCE']) {
delete process.env[key];
}
});
async function workloadToken(claims: Record<string, unknown>, audience = '0pt'): Promise<string> {
return await new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256' })
.setIssuer(ISSUER)
.setAudience(audience)
.setSubject('svc-0pt-worker')
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
}
describe('middleware: legacy internal token scope', () => {
beforeEach(() => {
process.env.INTERNAL_TOKEN = 'engine-shared-secret';
delete process.env.CL0UD_WORKLOAD_ISSUER;
});
it('single configured tenant becomes the verified tenant and the service scope', async () => {
process.env.INTERNAL_SERVICE_TENANTS = TENANT_A;
const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }));
expect(res.status).toBe(200);
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A);
});
it('an ambiguous multi-tenant scope is refused, not widened', async () => {
process.env.INTERNAL_SERVICE_TENANTS = `${TENANT_A},${TENANT_B}`;
const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' }));
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('OPT_UNAUTHENTICATED');
});
it('forged client-supplied verified headers are stripped', async () => {
process.env.INTERNAL_SERVICE_TENANTS = TENANT_A;
const res = await middleware(
req('/api/0pt/canonical/facts', {
'x-internal-token': 'engine-shared-secret',
'x-0pt-verified-tenant': TENANT_B,
[SERVICE_TENANT_SCOPE_HEADER]: TENANT_B,
}),
);
expect(res.status).toBe(200);
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A);
});
});
describe('middleware: CL0UD workload token (dual-accept)', () => {
beforeEach(() => {
process.env.CL0UD_WORKLOAD_ISSUER = ISSUER;
process.env.CL0UD_WORKLOAD_AUDIENCE = '0pt';
delete process.env.INTERNAL_TOKEN;
});
it('derives tenant and capabilities from verified claims', async () => {
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] });
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
expect(res.status).toBe(200);
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
expect(res.headers.get(`x-middleware-request-${SERVICE_CAPABILITIES_HEADER}`)).toBe('0pt.canonical.write');
});
it('rejects a token with the wrong audience', async () => {
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] }, 'n0d');
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
expect(res.status).toBe(401);
});
it('rejects a token that carries no tenant claim', async () => {
const token = await workloadToken({ capabilities: ['0pt.canonical.write'] });
const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` }));
expect(res.status).toBe(401);
});
it('ignores forged verified headers on the workload path too', async () => {
const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] });
const res = await middleware(
req('/api/0pt/canonical/facts', {
authorization: `Bearer ${token}`,
'x-0pt-verified-tenant': TENANT_B,
}),
);
expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A);
});
});
describe('tenant guard: scope and capability enforcement', () => {
it('denies a write for a tenant outside the service scope', () => {
const request = req('/api/0pt/canonical/facts', { [SERVICE_TENANT_SCOPE_HEADER]: TENANT_A });
expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull();
const denied = assertTenantMatchesVerified(request, TENANT_B);
expect(denied?.status).toBe(403);
});
it('denies a tenant that disagrees with a verified workload tenant', () => {
const request = req('/api/0pt/canonical/facts', { 'x-0pt-verified-tenant': TENANT_A });
expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull();
expect(assertTenantMatchesVerified(request, TENANT_B)?.status).toBe(403);
});
it('denies a missing capability on a capability-scoped principal', () => {
const request = req('/api/0pt/canonical/facts', { [SERVICE_CAPABILITIES_HEADER]: '0pt.canonical.read' });
expect(assertWorkloadCapabilities(request, ['0pt.canonical.write'])?.status).toBe(403);
expect(assertWorkloadCapabilities(request, ['0pt.canonical.read'])).toBeNull();
});
});

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