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.
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.
- 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.
- 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.
- One run per ref (concurrency) for CI, security, nightly and release; branch
pushes no longer duplicate pull_request runs (push is main-only).
- Deploys keep their own concurrency group so two runs never race Coolify.
- pnpm store cache on every Node job.
- New /api/version returns the deployed commit (no secrets).
- New scheduled prod-smoke workflow: readiness, db/stripe health, running
commit == main, and the document/credit routes exist (401/405, never 5xx).
The structure route ran the model with maximumCredits=1 and no reservation when
credits were off, so admission returned reservation_required and the route
answered 502. Admission now requires a reservation only when maximumCredits>0.
Verified against the live stack: DOC0 segments -> LiteLLM/Ollama
(inf0-valuation) -> evidence validation -> needs_review (no export).
Cloudflare replaces an origin 502 body with its own error page, so the JSON
message never reached the client. Log the reason server-side (codes/messages
only, never source text).
DOC0 denies an x-actor-id that the service identity has no actor_grant for, so
every upload/structure call from 0pt failed with DOC0_ACCESS_DENIED and became
outcome_unknown. The journal and project authorization keep the verified
end-user actor; only the DOC0 request acts as the service (DOC0_SERVICE_ACTOR_ID).
Zitadel does not always copy urn:zitadel:iam:org:id into the id_token; the
access token always carries it. Verify that token (issuer) before trusting its
tenant.
The portal had no way to obtain a verified principal: /api/0pt/* requires a
Bearer token, but the browser only had the dev-only HMAC cookie.
- GET /api/auth/login: Authorization Code + PKCE against Zitadel.
- GET /api/auth/callback: code exchange, id_token verification (issuer,
audience, nonce) and a signed session cookie with sub + tenant.
- middleware accepts the signed session as a verified principal; Bearer wins.
- dev login POST is refused in production.
- Sign-in link in the portal shell.
OPT_CREDITS_MODE=off skips reserve/settle and reports credits:null, so the
document flow can be piloted before prices are decided. It is never a silent
fallback and purchases remain disabled.
- createInf0Gateway prefers the live OpenAI-compatible LiteLLM gateway and
falls back to the native INF0 surface; admission stays local and fail-closed.
- Project authorization: OpenFGA when configured, otherwise a validated
server-owned OPT_PROJECT_GRANTS list (deny by default).
- Tests for the gateway adapter, the factory and grants.
- POST /api/0pt/documents/upload: real multipart bytes into DOC0 through the
durable attempt journal and the existing coordinator; verified tenant/actor
only, project authorization fail-closed.
- POST /api/0pt/documents/structure: DOC0 segments -> INF0 -> evidence-bound
validation -> optional JSON/CSV export, with credit reserve/settle.
- Credits: balance, server-owned price book, Stripe Checkout and idempotent
webhook reconciliation via a Stripe re-read of session + payment intent.
- /portal/dokument and /portal/krediter: SIAX-styled workspace.
- @siax/access: universal, framework-neutral OpenFGA project relationship
primitive reusable by DOC0/N0D/other products.
- Readiness: real Redis PING probe instead of presence-only; queue is now a
live dependency when configured.
- Dockerfile: copy every workspace manifest so --frozen-lockfile works.
- Format/build hygiene: .prettierignore, .worktrees ignored, valid manifest YAML.
Kernel 0.3.0 används som capability, inte som paket-dekor:
- lib/order-notes-store.ts: varje note ÄR en CollaborationOperation;
nextLamportClock för nya klockor, mergeOperationLog/compareOperations
för deterministisk listning; optimistisk version -> 409 vid stale skrivning.
- lib/order-notes-client.ts: kernel-CollaborationTransport över host-API;
offline = medvetet no-op + varning (ingen tyst förlust), 409 -> ConflictError.
- app/api/admin/order-notes: requireRouteAuth (fail-closed 401),
server-authoritative tenant-guard (403), AUD0-audit via hostens klient.
- app/admin/notes: admin-yta med offline-växel, konflikt-banner + reload.
- sql/016_order_notes.sql: persistens (DB-tabell, version + Lamport-klocka).
Tester: API (happy/conflict 409/empty/400/401/403/404), klient-adapter
(offline no-op utan fetch, 409-mappning, presence), SSR-render. 18 nya test.
Symmetrisk mot våg 3.4 CSV-export: POST /api/orders/import (validate |
dry_run | execute) + /admin/import som renderar @siax/ui ImportWizard
(0.14.0) över @siax/import-export runtime (0.2.0).
- Servern är enda auktoritet: RFC4180-parse (ingen eval), omvalidering
via frysta @siax/contracts-kerneln (validateRows) + hostregler,
exekvering genom BEFINTLIG orderlogik (createOrder, tenant-guardad).
- Fail-closed: 401 utan principal, 400 okänd header, 413 vid radtak
(500), per-rad-atomsäkra inserts — felrad kan aldrig skada OK-rader.
- Partiell import: OK-rader skapas, felrader rapporteras per rad
(row/field/code). AUD0-audit via 0pts enda producer-boundary
(durable outbox under DATABASE_URL).
- Reload-persistens: efter execute läses /api/orders om och de nya
raderna listas på sidan.
- Tester: 12 route-test (happy, partial, auth fail-closed, tak,
no-write validate, header, quoting, rad-fel mitt i batch) + 3
SSR-render-test av ImportWizard över runtime-storen.
- Deps: @siax/ui ^0.14.0, @siax/charts ^0.5.0, @siax/import-export
^0.2.0; pnpm-overrides till contracts 0.10.0 / import-export 0.2.0
(ui 0.14.0 deklarerar 0.11.0/0.2.1 som ännu ej publicerats).
Servern projicerar öppna ordrar (draft/quoted/in_production/review) på
universal-inbox-konvolverna via createInboxItem (buildOrderInboxItem i
lib/order-flow, ADR-0001-mönstret: policy/resolution i serverlagret).
Admin renderar InboxList (@siax/ui 0.11.1) ovanpå orderkön — resolve-
aktionen gateras genom advance_action-state (available krävs). Terminala
statusar ger ingen post. tests/order-flow 6/6; full svit 1144/1144; build ✓.
GET /api/orders returnerar per order frozen advance_action (resolveAction ur
lib/order-flow); admin renderar via ActionGate — policy i serverlagret,
presentation i klienten (DoD §21). Terminala/okända statusar → read_only med
varför-text i stället för missvisande 'klar'. tests/order-flow.test.ts 3/3;
full svit 1138 godkända; typecheck grön.
Live-auditen 2026-09-28 fann tre bryt: filter+sök försvann vid 'Tillbaka' från
detaljsida, flikval saknade URL-state, brödsmulan ledde till startsida.
- /katalog: q och filter kanoniskt i searchParams (debounce 250 ms på skrivtick,
router.replace scroll:false) — Tillbaka återställer exakt vy, delad länk öppnar
samma filtrering. Suspense-wrapper krävs av useSearchParams vid prerender.
- /katalog/[id]: flikval i ?tab= (overview = parametern frånvarande — kanonisk
URL), piltangentsnavigering oförändrad.
- Brödsmulan länkar /katalog (inte /#katalog → startsida).
Verifierat: blocking-gate typecheck med SAMMA 2 förbefintliga fel som main
(noll nya), typecheck:app noll katalog-fel.
axe-core 4.10.2 mot 0pt.app 2026-09-26: en enda regel, color-contrast,
impact=serious, 82 noder. Estatets största enskilda a11y-defekt. Alla 82
har samma form: en Tailwind-alfamodifierare på en textfärg.
Uppmätt WCAG 2.2-kontrast mot bägge temabakgrunderna i styles/globals.css:
text-muted-foreground 4.73:1 ljust 7.63:1 mörkt GODKÄNT
text-muted-foreground/70 2.71:1 4.20:1 faller
text-muted-foreground/60 2.29:1 3.37:1 faller
text-muted-foreground/50 1.96:1 2.67:1 faller
text-muted-foreground/40 1.69:1 2.11:1 faller
text-foreground/20 1.57:1 1.74:1 faller
Den omodifierade token klarar AA i bägge teman, så åtgärden är helt enkelt
att aldrig tona den. Elva textställen bytte till text-muted-foreground.
Kopieringsknappen i app/portal går från 1.57:1 till 4.73:1 i viloläge och
19.79:1 vid hover — den föll även 1.4.11 för icke-textkomponenter.
Toastens stängknapp klarade redan 1.4.11 (3.71:1) men blir 4.73:1.
Kvar med alfa är endast den dekorativa masthead-siffran i app/bestall, som
redan bär aria-hidden + select-none + pointer-events-none. Den är undantagen
både av WCAG och av axe, och guarden nedan tillåter den på just den grunden.
scripts/ci/check-text-contrast.mjs är en blockerande källkodsgrind i
quality-jobbet. Den är avsiktligt inte en axe-körning: den fullständiga
axe-jobb som UI-09 också begär kräver build + serve i CI och spåras separat.
Tills det finns stoppar den här grinden regressionen vid den form som
orsakade defekten.
Refs: UI-09
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mätt live 2026-09-25 mot produktionsdeployen (0pt.app / api.0pt.app,
container shxd6lepxuxfsjsp0ath3z1g-172514162615 på server6, uppe 17,9 h,
Docker-status "healthy" hela tiden):
GET /api/health -> HTTP/1.1 200 OK
{"ok":false,"service":"0pt-web","db":"ok","queue":"not_configured",
"engine":"error","stripe":"not_configured",
"detail":{"engine":"This operation was aborted"}}
GET /api/ready -> HTTP/1.1 200 OK
{"ready":true,"service":"0pt-web","db":"ok","engine":"error",...}
ENGINE_URL pekade på http://0pt-engine:8000. Mätt inifrån containern:
`getent hosts 0pt-engine` ger ingen post, och det finns ingen sådan
container på någon av server5-11. Ett direktanrop mot motorn misslyckas
efter ~5 s (DNS). Datamotorn — 0pt:s hela produkt — var alltså BORTA, och
samtliga signaler ovanför den var gröna: Docker healthy, /api/health 200,
/api/ready 200.
`ok:false` i en 200-kropp är ingen signal. Ingenting i kedjan läser
kroppar; allt läser statuskoder och exitkoder.
isReady skilde tidigare bara på "db ok". Den skillnad som saknades är inte
konfigurerad-vs-ej-konfigurerad utan FRÅNVARANDE-vs-TRASIG:
* not_configured / not_integrated -> fortsatt ready. 0pt är byggt för att
kunna köra utan motor, Stripe och kö (se .env.example).
* error -> INTE ready. Statusen "error" betyder att någon medvetet
konfigurerade beroendet och att en riktig round trip mot det just
misslyckades. Det är inte längre en valfri integration.
Ändrar ingenting i driften i dag: container-HEALTHCHECK pekar på
/api/health (liveness) och Dockerfilen säger uttryckligen att en
readiness-probe inte ska peka dit. Den här ändringen gör bara att den
ärliga ytan faktiskt blir ärlig, så en yttre övervakning kan använda den.
Regressionstest tillagt; verifierat att det FALLER mot den gamla isReady
(1 failed | 18 passed) och passerar mot den nya (24 passed över
health-checks/ready/health).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getSqlForDurableStore throws SiaxError(DEPENDENCY_UNAVAILABLE, httpStatus 503,
retryable) when a money-bearing store would fall back to an in-memory Map in a
production runtime (the durable-store guard). The /api/0pt/* surface already
maps it via src/http/siax-error-response.ts; the seven legacy routes outside
the middleware matcher did not — the documented 503 leaked as Next's opaque
500 with no body.
- app/api/siax-error-mapping.ts: shared isSiaxError + siaxErrorResponseForLegacy
- orders (POST/GET), orders/[id] (GET/PATCH), subscriptions (GET/POST),
payments/deposit, payments/release, admin/metrics: try/catch -> mapper,
each route's own typed error contract preserved (idempotency conflict 409 etc.)
- webhooks/stripe: store outage -> 503 received:false reason:store_unavailable
so Stripe's delivery retry path stays open (never a silent received:true)
- tests/api/legacy-routes-siax-error-mapping.test.ts: pins the 503 + body