diff --git a/.env.example b/.env.example index c979411d..e99a99d9 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,23 @@ OUTPUT=examples/production-live.pack.yaml # Per-request MCP timeout in milliseconds (default 30000, capped at 300000). # OBSERVOGRAM_MCP_TIMEOUT_MS=30000 +# Transport hook (docs/MCP_INTEGRATION.md "Transport hook"): a module whose +# prepareRequest({ url, headers }) and/or fetchImpl(url, init) shape EVERY +# MCP request — fetch-live, record-fixtures, the live probes, journeys and +# the studio server. A path (absolute, or relative to the working +# directory) or a file: URL. Loaded once per process; a hook that fails to +# load or breaks its contract is a hard failure (exit 1, nothing written; +# the server refuses to start). Unset: inert. +# OBSERVOGRAM_TRANSPORT_HOOK=./hooks/gateway.mjs + +# The MCP origin allowlist (README "Fetch Live From MCP"): comma-separated +# origins the studio server may send a credential to — an endpoint's +# server-held token, a caller's mcpAuth (every deploy), a credential in a +# typed URL, or anything while a transport hook is loaded. Loopback always +# passes. Unset: no credential leaves for any other origin. Per org: +# OBSERVOGRAM_ORG__MCP_ORIGINS (unioned with this one). `*` = any. +# OBSERVOGRAM_MCP_ORIGINS=https://otel-mcp.example.com + # --------------------------------------------------------------------------- # Identity (docs/PRODUCTIZATION_PLAN.md Stage 1). Three postures: # @@ -82,6 +99,49 @@ OUTPUT=examples/production-live.pack.yaml # "confirm": true); it applies to IdP users created after the change. #OBSERVOGRAM_OIDC_JOIN_ROLE= +# Identity from a reverse proxy (README "Behind a reverse proxy"). Opt-in: +# the proxy in front of this port terminates SSO and sends who the caller is +# in headers; no cookie, no login page. Safe ONLY when clients cannot reach +# this port — the ACK is a sentence you type once the proxy strips these +# headers from every client request. Beyond loopback the shared secret is +# required (the proxy sends it in the secret header). Users are recorded as +# proxy://# (kind oidc): `npm run users -- owner proxy://proxy#alice`. +# Groups → roles are authoritative in OBSERVOGRAM_PROXY_AUTH_ORG (default +# the default org); owner is grant-only. Refuses to start beside OIDC. +#OBSERVOGRAM_TRUST_PROXY_AUTH=1 +#OBSERVOGRAM_TRUST_PROXY_AUTH_ACK=only-the-proxy-reaches-this-port +#OBSERVOGRAM_PROXY_AUTH_USER_HEADER=X-Forwarded-User +#OBSERVOGRAM_PROXY_AUTH_EMAIL_HEADER=X-Forwarded-Email +#OBSERVOGRAM_PROXY_AUTH_NAME_HEADER= +#OBSERVOGRAM_PROXY_AUTH_GROUPS_HEADER=X-Forwarded-Groups +#OBSERVOGRAM_PROXY_AUTH_GROUP_ROLES=sre=admin,dev=operator,*=viewer +#OBSERVOGRAM_PROXY_AUTH_ORG= +#OBSERVOGRAM_PROXY_AUTH_JOIN_ROLE=none # first-sight role when no groups header is configured; refused beside one +#OBSERVOGRAM_PROXY_AUTH_OWNERS= +#OBSERVOGRAM_PROXY_AUTH_SHARED_SECRET= # >=32 chars; required beyond loopback +#OBSERVOGRAM_PROXY_AUTH_SECRET_HEADER=X-Proxy-Auth-Secret +#OBSERVOGRAM_PROXY_AUTH_LOGOUT_URL= + +# Rebadging (README, "Rebadge The Studio (brand config)"). One brand object +# feeds the studio shell, the chrome, the sign-in pages and the design +# tokens; unset, everything is Observogram and the shell is served byte for +# byte. A JSON file (name, shortName, wordmark, tagline, titleSuffix, +# description, logo {url|svg}, favicon, docsUrl, footer {text, links}, +# about {changelogUrl}, hero {src, alt}, tokens {light, dark}) and/or the +# scalars below, which win over the file. One name derives every other +# string. An unreadable or invalid file refuses the start. Also honoured by +# `npm run build:studio` when `--brand` is absent (the file and the scalars). +#OBSERVOGRAM_BRAND_FILE=./brand.json +#OBSERVOGRAM_BRAND_NAME= +#OBSERVOGRAM_BRAND_SHORT_NAME= +#OBSERVOGRAM_BRAND_TAGLINE= +#OBSERVOGRAM_BRAND_LOGO_URL= +#OBSERVOGRAM_BRAND_DOCS_URL= +#OBSERVOGRAM_BRAND_FOOTER= +#OBSERVOGRAM_BRAND_ACCENT= +#OBSERVOGRAM_BRAND_ACCENT_DARK= +#OBSERVOGRAM_PROXY_AUTH_REALM=proxy + # Tenancy (Stage 2 — workspace-per-org). No env var and no file: always on. # Every /api request runs in an org; a flat workspace is the default org, at # the workspace root. A legacy /orgs.json is imported once, at the @@ -124,10 +184,71 @@ OUTPUT=examples/production-live.pack.yaml # Run records kept per journey (oldest pruned after every run; 0 = unlimited). #OBSERVOGRAM_JOURNEY_RUN_RETENTION=1000 +# MCP read tokens an org's endpoints may name: OBSERVOGRAM_ORG__ +# (ORG = the org id, upper case, - → _; NAME of [A-Z0-9_]+). An admin of an +# org may name only its own (POST /api/mcp-endpoints { readTokenEnv }); the +# server reads the variable at request time when POST /api/refresh-live or +# /api/draft-from-mcp picks the endpoint by mcpEndpointId and the request +# sends no mcpAuth. The owning org is the one whose prefix is the longest +# match (OBSERVOGRAM_ORG_ACME_EU_X is acme-eu's, not acme's), so pick a +# NAME that is not another org's id followed by _, nor a notify hook's +# (OBSERVOGRAM_ORG__NOTIFY__URL or _TOKEN, below — refused). Never +# a write token: deploys and rollbacks take theirs per request. Set on the +# studio (the k8s Deployment's env, from a Secret), never on the CronJob. +#OBSERVOGRAM_ORG_DEFAULT_MCP_TOKEN= + # A journey's own env-var names — packB.mcp.authEnv, notify.urlEnv, -# notify.authEnv — are read literally (process.env[]), not through the -# OBSERVOGRAM_/TOMOGRAPH_ fallback: set exactly the names the journey file -# declares, e.g. +# notify.authEnv — are read literally by the CLI (packc journey run reads +# process.env[], not through the OBSERVOGRAM_/TOMOGRAPH_ fallback): set +# exactly the names the journey file declares, e.g. #MY_MCP_TOKEN= -#MY_JOURNEY_WEBHOOK_URL= -#MY_JOURNEY_WEBHOOK_TOKEN= +# +# The studio server reads only the journey's org's own notify hook (README, +# "Saved Journeys"; docs/DOWNSTREAM.md §17): notify.urlEnv must be +# OBSERVOGRAM_ORG__NOTIFY__URL (HOOK: words of A-Z and 0-9 joined +# by single _) and notify.authEnv that hook's _TOKEN, or absent; any other +# name refuses the server's run before anything runs (400), and capture +# refuses it. Name the hook in journeys the studio also runs; the CLI reads +# the same names. Set the pair here and restart; GET +# /api/journeys/notify-hooks lists the org's hooks (never a value). Below, +# the default org's hook ONCALL when its id is `default` (npm run orgs -- +# list). +#OBSERVOGRAM_ORG_DEFAULT_NOTIFY_ONCALL_URL= +#OBSERVOGRAM_ORG_DEFAULT_NOTIFY_ONCALL_TOKEN= +# +# The notify origin allowlist: comma-separated webhook origins +# (scheme://host[:port]; `*` = any) a server-run journey may post to. +# Loopback always passes. Unset: only loopback — a webhook off this machine +# needs its origin here. Per org: OBSERVOGRAM_ORG__NOTIFY_ORIGINS +# (unioned with this one). A separate list from OBSERVOGRAM_MCP_ORIGINS. An +# http: origin carries the URL and the token in clear. +#OBSERVOGRAM_NOTIFY_ORIGINS=https://hooks.slack.com + +# Artefact taxonomy override (README, "Classify Typed Packs"): a JSON file +# mapping foreign artefact type names and id patterns onto Observogram's +# families, so a pack from another toolchain gets the grouped Discover board +# and the full compare. Read once at start; an unreadable or invalid file +# refuses the start. Unset = Observogram's own families only. Also honoured +# by `npm run build:studio` when `--taxonomy` is absent. Schema version 2 +# adds a `glossary` (definitions the studio shows beside family labels and +# spec terms); a version 1 file stays valid. +#OBSERVOGRAM_TAXONOMY=./taxonomy.json + +# MCP server-settings policy (rebadge batch 4): a JSON file of rules that +# add friction to the studio's Server settings modal — a warning on a field +# value that matches a pattern, and an acknowledgement ticked before the +# send — and an optional `generic` block prefilling the generic form's path +# and field names. `{ "version": 1, "rules": [{ "when": { "field" | "type", +# "pattern", "flags"? }, "warn", "require"?: { "ack" } }], "generic"?: {...} }`, +# strict (an unknown key is refused). Read once at start; an unreadable or +# invalid file refuses the start. Unset = no policy. +#OBSERVOGRAM_MCP_SETTINGS_POLICY=./mcp-settings-policy.json + +# The MCP server-settings pass-through (rebadge batch 4): `1` lets the +# studio's Server settings modal send through the studio server +# (POST /api/mcp-settings/describe and /submit, admin only) when the browser +# cannot reach the MCP server or it sends no CORS headers. The studio server +# then holds the typed settings for one request — never logged, kept or +# audited by value — and passes back only the outcome shape. Read per +# request. Unset (the default): the browser talks to the MCP server directly. +#OBSERVOGRAM_MCP_ADMIN_PROXY=1 diff --git a/.gitattributes b/.gitattributes index f669389d..9a11a4a3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ +# Every text file is LF so goldens, fixtures and the vendor manifest's sha256 +# verify under any core.autocrlf (the rules below stay as the explicit list). +* text=auto eol=lf + # Vendored upstream spec files MUST remain byte-identical to upstream so the # sha256 checksums in vendor/observability-pack-spec/VERSIONS.json verify on # every platform. Force LF regardless of core.autocrlf. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 518c078d..62227abe 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,9 @@ is the condensed, agent-facing version — when in doubt, those docs win. render the affected view, check the console) before the work is called done. - Conventional commit messages (`feat(server): …`, `fix(studio): …`, `docs: …`) whose body says *why*, not just what. +- A red CI leg on develop, Linux or Windows, is fixed forward or reverted + within a working day; the summary at the end of the job's log names the + failure. ## Pinned sources of truth — edit the source, run its gate @@ -37,9 +40,12 @@ them; change them at the source and update the gate in the same commit. fails the build. Changing the registry requires updating `EXPECTED_TOOL_SURFACE` in that guard in the same commit; the diff is the wire-surface changelog. -- **Compiler and crawler output is golden-gated.** Any change to what the - crawler or a compiler *emits* must pass `npm run test:golden` (crawl) and - `npm run test:golden:compile` (per target, per version band). For intended +- **Compiler, crawler and board output is golden-gated.** Any change to what + the crawler or a compiler *emits* must pass `npm run test:golden` (crawl) + and `npm run test:golden:compile` (per target, per version band); any + change to how artefacts are classified or grouped must pass + `npm run test:golden:board` (the Discover board and the artefact families + of every catalogue pack). For intended output changes, regenerate with the matching `:update` script in the same commit and explain the golden diff in the commit/PR description. An unexplained golden diff is a blocker, not a formality. @@ -47,6 +53,14 @@ them; change them at the source and update the gate in the same commit. `tools/lib/profiles.mjs` and `tools/lib/protocols.mjs`, each fact with an upstream `evidence:` reference. Do not scatter version checks or feature knobs into compiler code. +- **The vendorable module surface** is `VENDOR-MANIFEST.json`, generated by + `npm run vendor-manifest` (`tools/gen-vendor-manifest.mjs`) and never + hand-edited. Changing a listed `tools/lib` module regenerates it in the + same commit; changing a listed module's *exports* also names the module + under `## Unreleased` in `docs/CHANGELOG.md` (path or backticked + basename) — `tools/test-vendor-manifest.mjs` fails the build otherwise. + A new module under `tools/lib` is listed or added to `EXCLUDED` with a + reason; the guard refuses neither. `docs/DOWNSTREAM.md` is the workflow. - **The vendored spec** under `vendor/observability-pack-spec/` is synced, never hand-edited: use `npm run sync-spec`; `npm run sync-spec:check` verifies SHA256 integrity and runs in CI. @@ -58,18 +72,42 @@ them; change them at the source and update the gate in the same commit. `server/test-authz.mjs` fails the build on an unclassified, unguarded or unprobed route (add its probe to `PROBES`); a class change updates `EXPECTED_CLASS` there in the same commit. -- **Identity rules:** every user, org, membership and join-role rule lives - once in `server/identity-admin.mjs`, applied by the CLIs (`npm run users`, - `npm run orgs`) and by the identity API (`server/routes/identity.mjs`, - with `surface: 'api'`, which changes only the way out a refusal names). - A refusal's `kind` (invalid / missing / conflict → 400 / 404 / 409) and - both surfaces' texts are pinned in `server/test-store.mjs`; never fork a - rule into a route. +- **Identity and service rules:** every user, org, membership and join-role + rule lives once in `server/identity-admin.mjs`, applied by the CLIs + (`npm run users`, `npm run orgs`) and by the identity API + (`server/routes/identity.mjs`, with `surface: 'api'`, which changes only + the way out a refusal names); every service, environment and MCP endpoint + rule — the tier enum, the MCP URL word rule and the per-org + `OBSERVOGRAM_ORG__*` token variable, an MCP target picked by + `mcpEndpointId` — lives once in `server/service-admin.mjs` (over + `server/store/mcp-endpoints.mjs`), applied by `server/routes/services.mjs` + and the refresh, draft and deploy routes; the audit reader's query rule + — the scope by principal, every filter, the 500-row cap — lives once in + `server/audit-admin.mjs` (`GET /api/audit` in `server/routes/audit.mjs`; + `packc store audit` in `tools/store-admin.mjs` — the `cli` surface of the + same rule), + and the file-first routes write their row through + `server/audit-after.mjs`, after the file, never inside the operation. A + refusal's `kind` (invalid / missing / conflict → 400 / 404 / 409) and the + texts are pinned in `server/test-store.mjs`, + `server/test-service-admin.mjs`, `server/test-services-api.mjs` and + `server/test-audit-api.mjs`; never fork a rule into a route. - **Browser-safety rule:** modules under `tools/lib/` (including `tools/lib/contracts/`) are imported by the studio in the browser — they must not import `node:*` APIs or read `process.env`. Node-only code lives in `tools/*.mjs`, `server/`, or `tools/lib/journey.mjs` (the known exception). +- **Studio conventions** (`docs/UI_CONVENTIONS.md`): views import + `studio/host.mjs`, never `app.mjs`; a journey's actions ride + `host.` (`host.build`, `host.services`, `host.settings`); the + services and Settings loaders go through `requestJson()` / + `servicesRefusal()` in `studio/services-api.mjs` (never a bare `fetch`, + never a raw body in a status line); a `.svc-*` rule lives under the + `==== Services` marker and a `.set-*` rule under the `==== Settings` marker + in `studio/app.css`, which the AA scan in `tools/test-build-model.mjs` + reads; a secret (an MCP token, a password) is never logged, stored or put + in a title or link — the token is a server variable's name, and a temporary + password is shown once, in its dialog, and nowhere else. ## Git hygiene diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48b5d03e..917661c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: ci -# Validates every push to develop/main and every PR targeting main. +# Validates every push to develop/main and every PR into develop or main. # Runtime deps are installed with `npm ci` (express is a dependency). on: @@ -20,7 +20,7 @@ concurrency: jobs: validate: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 13 steps: - uses: actions/checkout@v7 @@ -71,14 +71,25 @@ jobs: - name: Verify vendored spec checksums run: npm run sync-spec:check + - name: Verify VENDOR-MANIFEST.json is in sync (tools/gen-vendor-manifest.mjs) + run: npm run vendor-manifest:check + - name: Validate canonical example against the vendored spec schema (tools/lib/validator.mjs SPEC_VERSION) run: npm run validate - name: Backend validation — strict (promtool / amtool / otelcol-contrib) run: npm run test:backend:strict + # bash -eo pipefail (named, so tee cannot hide the suites' exit), the + # TAP to a file for the summary at the end of this log, a step timeout + # under the job's so the summary still runs on a hang. The step timeout + # is twice the slowest c8 run measured on the runner (3m48s), rounded + # up; the job's adds twice the slowest steps before it (2m26s). - name: Regression suites + coverage (adapt / fetch / packs / compile / profiles / crawl / server) - run: npm run coverage + id: test + shell: bash + timeout-minutes: 8 + run: npm run coverage 2>&1 | tee "$RUNNER_TEMP/test.tap" - name: Upload coverage (lcov) if: always() @@ -92,11 +103,19 @@ jobs: run: | node -e "const fs=require('fs');const html=fs.readFileSync('studio/index.html','utf8');if(!html.includes(''))process.exit(1);console.log('studio html ok ('+html.length+' bytes)')" + # The end of this log (tools/tap-summary.mjs): the totals, every + # failure with its place and error, annotations; no win32 skip here. + - name: Test summary (the end of this log) + if: ${{ !cancelled() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') }} + run: node tools/tap-summary.mjs "$RUNNER_TEMP/test.tap" --win32-skips 0 + # The Node floor. package.json engines says >=22.16.0 (node:sqlite, the # embedded store — docs/STORE_PLAN.md §1), and `node-version: '22'` # above resolves to the latest 22.x, which would never exercise the # floor: code could lean on a later 22.x API or fix and still pass. - # This leg runs the suite on exactly the floor. + # This leg runs the suite on exactly the floor. It ends with the summary + # (tools/tap-summary.mjs), and its run's shape is the reference + # windows-vs-linux holds the Windows runs to. node-floor: runs-on: ubuntu-latest timeout-minutes: 10 @@ -112,7 +131,111 @@ jobs: run: npm ci - name: Test suites on the Node floor - run: npm test + id: test + shell: bash + timeout-minutes: 7 + run: npm test 2>&1 | tee "$RUNNER_TEMP/test.tap" + + - name: Test summary (the end of this log) + id: summary + if: ${{ !cancelled() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') }} + run: node tools/tap-summary.mjs "$RUNNER_TEMP/test.tap" --win32-skips 0 --shape "$RUNNER_TEMP/run-shape.json" + + - name: Keep the run's shape for windows-vs-linux + if: ${{ !cancelled() && steps.summary.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: run-shape-node-floor + path: ${{ runner.temp }}/run-shape.json + retention-days: 7 + overwrite: true + + # Windows (README "Platforms", docs/DOWNSTREAM.md §13): the suites on + # windows-latest, on the Node floor and the latest 22. No fetch-validators + # (test-backend-validate prints SKIPPED, as on node-floor), no docker, no + # Playwright (the browser tests skip, as on every leg). The TAP goes to + # the log and to a file; the summary step prints the block the log API's + # tail reaches, annotates each failure, and fails the job unless the run + # printed the SKIP win32: count README states. The run's shape goes to + # windows-vs-linux, which holds it to the Linux floor's. + windows: + name: windows (node ${{ matrix.node }}) + runs-on: windows-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + node: ['22.16.0', '22'] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # The report command docs/DOWNSTREAM.md §13 gives a Windows user, run + # as written from Windows PowerShell on one suite: it must capture TAP. + - name: The documented report command captures TAP + shell: powershell + run: | + cmd /c "set NODE_OPTIONS=--test-reporter=tap&& npm run test:platform > test.tap 2>&1" + if ($LASTEXITCODE) { Get-Content test.tap -Tail 40; exit $LASTEXITCODE } + node tools/tap-summary.mjs test.tap --win32-skips 0 + $code = $LASTEXITCODE; Remove-Item test.tap; exit $code + + # The step timeout is twice the slowest npm test measured on the runner + # (3m39s), rounded up to 5 minutes and at least 15; the job's is 10 more. + - name: Test suites on Windows + id: test + timeout-minutes: 15 + run: npm test 2>&1 | tee "$RUNNER_TEMP/test.tap" + + - name: Test summary (the end of this log) + id: summary + if: ${{ !cancelled() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') }} + run: node tools/tap-summary.mjs "$RUNNER_TEMP/test.tap" --win32-skips readme --shape "$RUNNER_TEMP/run-shape.json" + + - name: Keep the run's shape for windows-vs-linux + if: ${{ !cancelled() && steps.summary.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: run-shape-windows-${{ matrix.node }} + path: ${{ runner.temp }}/run-shape.json + retention-days: 7 + overwrite: true + + # Windows ran what Linux ran: the same tests, the same harness assertions + # per file (fewer only under a counted win32 skip), the same skip reasons + # (tools/tap-summary.mjs --compare; README "Platforms"). + windows-vs-linux: + needs: [node-floor, windows] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22.16.0' + + - uses: actions/download-artifact@v8 + with: + pattern: run-shape-* + path: ${{ runner.temp }}/shapes + + - name: The Windows runs against the Linux floor + run: >- + node tools/tap-summary.mjs --compare + "$RUNNER_TEMP/shapes/run-shape-node-floor/run-shape.json" + "$RUNNER_TEMP/shapes/run-shape-windows-22.16.0/run-shape.json" + "$RUNNER_TEMP/shapes/run-shape-windows-22/run-shape.json" # T4 — the product-story round trip against a REAL Grafana: # compile → deploy-bulk (real MCP path) → fetch-live → diff → aligned. diff --git a/.gitignore b/.gitignore index b4146ec7..e99d1a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,9 @@ docs/archive/TOMOGRAPH_MIGRATION_CHECKLIST.md # Superseded scratch versions of the brand/architecture diagrams. docs/diagrams/_prev* +# `npm run build:studio` output (tools/build-studio-bundle.mjs): the studio +# as one static HTML file. A build product, never source. +/dist/ + # Generated provisioning files for the T2 provisioning-load test. docker/.t2-provisioning/ diff --git a/AGENTS.md b/AGENTS.md index 7e873abd..64e5da41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Agent instructions Read [.github/copilot-instructions.md](.github/copilot-instructions.md) — the -canonical agent guidance for this repo. The three rules agents break most: +canonical agent guidance for this repo. The four rules agents break most: 1. **Never hardcode an MCP tool name.** They live only in `tools/lib/contracts/mcp-capabilities.mjs`; `tools/test-contract-guard.mjs` @@ -9,7 +9,11 @@ canonical agent guidance for this repo. The three rules agents break most: 2. **Output changes are golden-gated.** `npm run test:golden` and `npm run test:golden:compile` must pass; intended changes regenerate goldens (`:update`) in the same commit with the diff explained. -3. **Branch contract:** `codex/` from `origin/develop`, PR into +3. **`VENDOR-MANIFEST.json` is generated.** Touching a `tools/lib` module + means `npm run vendor-manifest` in the same commit; changing its exports + also means naming it under `## Unreleased` in `docs/CHANGELOG.md` + (`tools/test-vendor-manifest.mjs`). +4. **Branch contract:** `codex/` from `origin/develop`, PR into `develop`; `main` advances only by promotion PR. `npm run lint` + `npm test` green before every push. No build step — plain ESM, not TypeScript. diff --git a/Dockerfile b/Dockerfile index 72ea453e..c29b2bd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,9 @@ WORKDIR /app # Dependency layer first so source edits don't bust the npm cache. # build.json* — the stamp above when it exists, nothing otherwise. -COPY package.json package-lock.json build.json* ./ +# LICENSE — Observogram's MIT licence, which travels with the code the image +# runs (each npm package's own licence file is in its node_modules directory). +COPY package.json package-lock.json LICENSE build.json* ./ RUN npm ci --omit=dev # Everything the server reads at runtime (see server/index.mjs): diff --git a/README.md b/README.md index 0c45d8b4..d4cff73b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ *(formerly **Tomograph** — pre-rebrand env vars, headers, workspaces, and pack annotations keep working; see docs/CHANGELOG.md.)* **Observogram is the observability compiler and diagnostic workspace for -ObservabilityPack spec v1.3.** +ObservabilityPack spec v1.4.** It answers one operational question: @@ -35,7 +35,7 @@ The gap between those two packs is the diagnostic finding. The canonical specification lives at [MoebiusX/otel-observability-pack](https://github.com/MoebiusX/otel-observability-pack). A checksummed copy is vendored under -[`vendor/observability-pack-spec/v1.3/`](vendor/observability-pack-spec/v1.3/). +[`vendor/observability-pack-spec/v1.4/`](vendor/observability-pack-spec/v1.4/). ## Why It Exists @@ -61,6 +61,16 @@ or *build a new pack* (Define · Compile · Verify). The two paths join at *Pack available in Discover*: a compiled pack enters the same audit journey as an imported one, with its unresolved placeholders still visible. +The home is the org's **services**, one card per record (tier, owners, the packs +linked, the verdict per environment), and a card opens the **service page**: the +environments as tabs with the verdict the pack is graded to at the record's tier +and the MCP endpoint it is checked through, and the four actions — Discover · +Diagnose · Remediate · Build — bound to that service and environment through the +newest primary pack ([Services, Environments And MCP +Endpoints](#services-environments-and-mcp-endpoints)). Catalogue packs are listed +apart; what a viewer can open and what only an operator can is drawn from the role +the server reports for the active org. + ### No pack yet? Build one A second, parallel journey for a service that has no pack: **Build** — three @@ -216,6 +226,21 @@ The drift drill shows: - live-only shadow signals - out-of-scope live inventory that belongs to the wider platform +**Compare** (Diagnose → Compare) puts the two packs side by side. Its stat +bar counts what is only in A, in both and only in B, and its **paired by** +cell says how the pairs were formed. Under it, the **Pair by** switch chooses +the key: **Behaviour** (the default — what each artefact does: a series, a +product and signal, a contract handle), **Name** (the name or title) or **Id** +(a stable id or uid, such as a Grafana dashboard uid; a family whose name is +its id uses the name). Name and Id are worked out in your browser over the two +packs already on screen — no new request — with the same vendorable +`tools/lib/identity-modes.mjs` and `diff.mjs` the server runs; behaviour still +decides whether a pair matches field for field, so a dashboard renamed under +the same uid pairs by id and shows its title change as drift. An in-both +card's badge says which key paired it (`Paired by id: dashboard uid +"ord-1"`), and so does its detail drawer. Chains, the Assessment and every +action keep pairing by behaviour. + Traceability shows requirement chains from SLO to SLI, metrics, recording rules, exporters, scrape evidence, dashboards, alerts, and runbooks. @@ -235,6 +260,17 @@ Deployable artifacts can be pushed through an MCP write target. Non-deployable or inferred artifacts remain visible as manual follow-up, not silent production changes. +When the pack declares remediations (`spec.remediation`), Diagnose and +Remediate also draw the **response path**: each remediation, the alert its +`trigger: alert:` names (matched by rule name, compiled burn-rule name +or SLO, or declared outright with the annotation +`observogram.remediates.remediation[]` — `docs/ADAPTER.md` "Response +path"), that alert's state against live, and what next — deploy the missing +burn alert, reconcile the drifted one, route the severity, register the +automation, open the runbook. A trigger no alert answers to is listed as +unresolved with name-based suggestions; nothing is inferred into a link. Pure +pack data, no external call; a pack without remediations shows nothing. + ![Observogram Remediate view showing the Pack A minus Pack B deploy delta](docs/img/xray-remediate.png) ## Quickstart @@ -243,8 +279,64 @@ Requires **Node 22.16 or later** (`engines` in `package.json`). The studio keeps users and orgs in an embedded store on Node's built-in `node:sqlite` (services and environments follow), which is unflagged from 22.13; 22.16 is the floor because it fixes a `StatementSync` use-after-free and the `run()` -statement reset that a store hits. CI runs the suites on 22.16.0 and on the -latest 22. See [docs/STORE_PLAN.md](docs/STORE_PLAN.md). +statement reset that a store hits. CI runs the suites on Ubuntu and on +Windows, each on 22.16.0 and on the latest 22. See +[docs/STORE_PLAN.md](docs/STORE_PLAN.md). + +### Platforms + +Linux and Windows are tested in CI: `npm test` runs on Ubuntu and on +`windows-latest` (GitHub-hosted, an elevated runner, Defender as the image +ships it), each on Node 22.16.0 (the floor) and the latest 22, on every PR +into develop or main and every push to them; the latest-22 Ubuntu run is under +c8. The browser suites (Playwright) run on neither: they skip, as on any +checkout without `OBSERVOGRAM_PLAYWRIGHT`. Windows (Node ≥ 22.16, PowerShell +or cmd) runs the CLI, the server, the studio and the static bundle, and +`npm test` is green there except the explicit skips — every one printed by +`node --test` as `# SKIP win32: ` (or `- SKIP win32: ` in a +harness suite), never a silent pass — for facts Windows cannot express: POSIX +mode bits (`0600`/`0644`, `chmod 0000`), signal delivery (`process.kill` ends +a Windows process outright, so the store's SIGTERM/SIGINT self-close cannot be +exercised) and symlink creation (a privilege; skipped on every Windows host, +elevated or not, so the count is one number everywhere). There are +15 win32-skip sites — `server/test-store.mjs` (8), +`server/test-store-ops.mjs` (4), `server/test-store-import.mjs` (2), +`tools/test-journey.mjs` (1) — and 19 `SKIP win32:` lines once the loops +unroll, plus the PID 1 namespace test's +`# SKIP unshare --pid is unavailable here` and the Playwright skips. The +Windows leg fails unless a run prints exactly that number, and unless it ran +the tests and the harness assertions the Ubuntu floor ran: +`tools/tap-summary.mjs` holds it and prints the totals and every failure at +the end of the log. When the leg first went green its summary printed Windows +10.0.26100 on 4 CPUs, Node v22.16.0 and v22.23.3 (the latest 22), the checkout +on `D:` and `TEMP` an 8.3 short name on `C:` +(`C:\Users\RUNNER~1\AppData\Local\Temp`), a symlink created, and the tests the +Ubuntu floor ran; `npm test` takes 138–219 s there. A Windows box of your own +reports with the command in [docs/DOWNSTREAM.md](docs/DOWNSTREAM.md) §13. + +On Windows, today: + +- The database, its backups and `users.json` are protected by their + directories' ACLs: Windows does not enforce `0600`. Limit the workspace + directory (and the database's, where `OBSERVOGRAM_DB` puts it elsewhere) + to the account the server runs as, e.g. from cmd: + `icacls C:\observogram /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`. +- The store refuses a network filesystem on Linux only. On Windows, keep the + database on a local disk, not a share or a mapped drive. +- Journey names that differ only in case share one definition file and one + runs folder. +- `packc journey list`, `GET /api/journeys` and `packc journey run --all` + follow the directory's order, which on NTFS is case-insensitive. + +`tools/test-platform.mjs` (`npm run test:platform`) keeps the sites count and +the reasons honest, requires this section to state the lines count, and +proves the module-relative path resolvers (`fileURLToPath`, never +`URL.pathname`) on every platform. Every suite names the platform only +through `server/fixtures/platform.mjs` (`isWin32`, `isLinux`, `PLATFORM`, +`win32Skip(reason)`, `skipOnWin32(t, reason)`, and `WIN32_PLATFORM` for a +suite that fakes the platform as data). The checkout is LF everywhere +(`.gitattributes`), so goldens and `VENDOR-MANIFEST.json`'s hashes verify +under any `core.autocrlf`. macOS is not in CI; the POSIX suites run there. ```bash git clone https://github.com/MoebiusX/Observogram.git @@ -256,11 +348,15 @@ npm run dev Open `http://127.0.0.1:8000` and sign in with **admin / admin** — first boot seeds this default user and asks for a password change at sign-in (skippable for now; it asks again each sign-in until a real password -lands — or change it any time from the account menu, top right once a -pack is open, which also has **sign out my other sessions**). From +lands — or change it any time from the account menu, top right on every +screen, which also has **sign out my other sessions**). From there it's a signed-in app: your packs, deploy audit and run history belong to you. (`OBSERVOGRAM_AUTH=off` skips login entirely -for a throwaway open sandbox.) +for a throwaway open sandbox.) To draft from a live MCP there, the home's +**Register and connect** registers a loopback MCP as the org's endpoint, +tests the connection and offers Draft or Snapshot; a remote MCP needs its origin listed first — +`OBSERVOGRAM_MCP_ORIGINS=https://mcp.example.com npm run dev` — or a signed-in +admin ([Fetch Live From MCP](#fetch-live-from-mcp)). ### Security Posture @@ -276,12 +372,14 @@ for a throwaway open sandbox.) wherever you need (`HOST=0.0.0.0`). Mutating `/api/*` routes (crawl, draft, validate-register, deploy, verify, reset) then require `Authorization: Bearer `; read routes stay open. Set - `OBSERVOGRAM_API_TOKEN_LABEL=` to stamp the deploy audit - log with the token's ownership — the secret itself never lands in any - log. The label must not be a user's login: the audit log names the - bearer by it (default `token`), and the identity API refuses a new user - by that name. A token configured on a fresh workspace suppresses the - default-admin seed: the token is the expressed auth intent. + `OBSERVOGRAM_API_TOKEN_LABEL=` to stamp `deploys.jsonl` + and the audit ([The Audit](#the-audit)) with the token's ownership — the + secret itself never lands in any log. The label must not be a user's + login: the audit names the bearer by it (default `token`), and the + identity API refuses a new user by that name. A token configured on a fresh workspace suppresses the + default-admin seed: the token is the expressed auth intent. In the + studio, Settings opens read-only there, under a banner that is the + server's own sentence naming the way in. 3. **Exposed without any auth.** The server **refuses to start** with a clear message. `OBSERVOGRAM_INSECURE_NO_AUTH=1` overrides knowingly (it logs a loud warning) for trusted-network demos only. @@ -358,7 +456,99 @@ server opens it at every start. - **Journeys run from the studio** load by name only, and a journey's `crawl:` roots, `file:` packs and inventory site are read only from the org's own part of the workspace (or outside it); a path inside another - org's part is refused. + org's part is refused. On Windows a path is read only by its name on a + local drive or, when the workspace is named through a network share, by + that share's name (or a drive mapped to it) inside the workspace; any + other name through a share or a mapped drive is refused — the org's own + part included, even with one org — and the refusal says to name it by its + path on a local drive (copy it there if it has none). + +#### Behind a reverse proxy (trusted headers) + +An enterprise deployment that terminates SSO at a reverse proxy can have the +server trust the identity the proxy forwards, instead of running OIDC or +local users itself ([`server/auth-proxy.mjs`](server/auth-proxy.mjs); the +auth seam of [docs/DOWNSTREAM.md](docs/DOWNSTREAM.md)). Opt-in, and off +unless `OBSERVOGRAM_TRUST_PROXY_AUTH=1`: without it no header is read, in +any posture. + +```bash +OBSERVOGRAM_TRUST_PROXY_AUTH=1 +OBSERVOGRAM_TRUST_PROXY_AUTH_ACK=only-the-proxy-reaches-this-port +OBSERVOGRAM_PROXY_AUTH_USER_HEADER=X-Forwarded-User # default; required on every request +OBSERVOGRAM_PROXY_AUTH_EMAIL_HEADER=X-Forwarded-Email # default +OBSERVOGRAM_PROXY_AUTH_NAME_HEADER= # unset by default +OBSERVOGRAM_PROXY_AUTH_GROUPS_HEADER=X-Forwarded-Groups # unset by default; a comma list +OBSERVOGRAM_PROXY_AUTH_GROUP_ROLES='sre=admin,dev=operator,*=viewer' +OBSERVOGRAM_PROXY_AUTH_ORG= # the org the groups rule; default the default org +OBSERVOGRAM_PROXY_AUTH_JOIN_ROLE=none # first-sight role when no groups header is configured (refused beside one) +OBSERVOGRAM_PROXY_AUTH_OWNERS=root # comma list of user values granted owner +OBSERVOGRAM_PROXY_AUTH_SHARED_SECRET=<32+ chars> # required beyond loopback +OBSERVOGRAM_PROXY_AUTH_SECRET_HEADER=X-Proxy-Auth-Secret # default +OBSERVOGRAM_PROXY_AUTH_LOGOUT_URL=https://sso.example.com/logout +OBSERVOGRAM_PROXY_AUTH_REALM=proxy # default; [a-z0-9._-]{1,64} +``` + +- **The rule.** The proxy MUST be the only route to this port, and MUST + strip the user, email, name, groups and secret headers from every client + request before adding its own — a client that can set `X-Forwarded-User` + is anyone. The server cannot verify that, so it refuses to start until + the operator says so in words: `OBSERVOGRAM_TRUST_PROXY_AUTH_ACK` must be + the sentence `only-the-proxy-reaches-this-port` (not `1`). The refusal: + `OBSERVOGRAM_TRUST_PROXY_AUTH=1 trusts identity headers from a reverse + proxy, which is safe only when clients cannot reach this port — set + OBSERVOGRAM_TRUST_PROXY_AUTH_ACK=only-the-proxy-reaches-this-port once the + proxy strips X-Forwarded-User, X-Forwarded-Email[, ] from + every client request, or unset OBSERVOGRAM_TRUST_PROXY_AUTH`. +- **Loopback, or a shared secret.** Bind to loopback next to the proxy, or + set `OBSERVOGRAM_PROXY_AUTH_SHARED_SECRET` (32 characters or more) and have + the proxy send it in `X-Proxy-Auth-Secret`: a request without the right + secret is anonymous. A bind beyond loopback without it refuses to start + (`refusing to bind to with OBSERVOGRAM_TRUST_PROXY_AUTH=1 and no + OBSERVOGRAM_PROXY_AUTH_SHARED_SECRET: beyond loopback any client could set + X-Forwarded-User. Set the shared secret (the proxy sends it in + X-Proxy-Auth-Secret), or bind to loopback (HOST=127.0.0.1) next to the + proxy`); on loopback without it the boot warns once that every process + reaching the port is trusted as the proxy. The secret is compared in + constant time and never printed or recorded. +- **One sign-in mode per server.** `OBSERVOGRAM_OIDC_ISSUER` set beside the + flag refuses to start (`… are both set: one sign-in mode per server — + unset one`); `OBSERVOGRAM_AUTH=off` wins over the flag (one warn line, + headers not read). Local users are not consulted while the mode is on. +- **No cookie, no login page.** The headers are the session: every request + resolves them against the store, once, in one transaction. `GET + /auth/login` answers 401 and says so; the studio's **sign out** goes to + `OBSERVOGRAM_PROXY_AUTH_LOGOUT_URL` when set (the proxy's session outlives + this app's) and otherwise says it signed out of this app only; **sign out + my other sessions** is not offered. The headers are ambient like a + cookie, so a session mutation still needs `X-Observogram-CSRF: 1`. +- **Rows and roles.** Users are recorded as `proxy://#`, kind + `oidc`, under the key `proxy://` — `npm run users -- owner + proxy://proxy#alice`, `remove`, `enable` and the identity API name them + that way, whatever OIDC issuer the store recorded (a store that recorded + one keeps refusing bare logins from a plain shell, as before). The email + the proxy sends is recorded as verified. With a groups header configured, + the groups are **authoritative in `OBSERVOGRAM_PROXY_AUTH_ORG`** (default + the default org) and nowhere else: on every request that carries the + header the membership there is added, raised, lowered or removed to match + (`membership.jit` / `membership.role` / `membership.remove` rows, actor + `system`, `via: proxy-groups`; an admin's manual edit in that org is + overwritten by the next request); a request without the header leaves + memberships alone; `*` is every user the header names. Without a groups + header, `OBSERVOGRAM_PROXY_AUTH_JOIN_ROLE` applies at the first sight + only; beside a groups header it refuses the start (`… are both set: with + a groups header the groups rule every membership …`), since it would + otherwise apply whenever the proxy omits the header. A user in `OBSERVOGRAM_PROXY_AUTH_OWNERS`, or in a group mapped to + `owner`, is granted owner once (`owner.grant`, `via: proxy`) and **never + revoked here** — revoke with `PUT /api/admin/users/:id/owner`. A disabled + row is refused and never re-enabled by a request; a local row holding the + login is refused. +- **Refused requests are anonymous (401), and write nothing**: a duplicated + identity header line, a value over 2000 characters or holding a control + character, an empty user header, a missing or wrong secret. +- **`packc store rekey-issuer --clear` disables `proxy://` rows too**: they + are kind `oidc`, and `--clear` disables every enabled OIDC row. `--to` + rewrites one issuer's prefix and leaves them alone. #### Roles @@ -369,16 +559,16 @@ the server registers, and each route's first handler is its guard. | Role | May | |---|---| | `viewer` | every read (`GET`) in the org | -| `operator` | every existing write in the org as well: scan, draft, register, instantiate and compile, deploy, verify and roll back, retrofeed, journeys, the live refresh, RESET | -| `admin` | the org's name and members as well ([the identity API](#the-identity-api)) | -| owner | a deployment-level flag, not an org role: an owner acts as `admin` in every org, plus users, orgs and the join role ([the identity API](#the-identity-api)) | +| `operator` | every existing write in the org as well: scan, draft, register, instantiate and compile, deploy, verify and roll back, retrofeed, journeys (a journey's `notify` names one of the org's notify hooks — no role types a webhook URL), the live refresh, testing an MCP connection and live jobs against a registered endpoint ([Test An MCP Connection And Build A Live Pack](#test-an-mcp-connection-and-build-a-live-pack)), RESET, and the org's services and environments ([Services, Environments And MCP Endpoints](#services-environments-and-mcp-endpoints)) | +| `admin` | the org's name, members and MCP endpoints as well ([the identity API](#the-identity-api), [Services, Environments And MCP Endpoints](#services-environments-and-mcp-endpoints)), a typed MCP URL in a ping, a live job, a draft, a live refresh, a deploy, a rollback or a journey's Pack B ([Fetch Live From MCP](#fetch-live-from-mcp); below admin, and without sign-in, only a registered endpoint), cancelling another member's live job, and the org's audit (`GET /api/audit`, [The Audit](#the-audit)) | +| owner | a deployment-level flag, not an org role: an owner acts as `admin` in every org, plus users, orgs and the join role ([the identity API](#the-identity-api)) and the deployment's audit (`GET /api/audit?scope=all`) | The role is the membership **of the request's org** (`X-Observogram-Org`, `?org=`). `orgs.json` roles are mapped on import: `admin` / `owner` → `admin`, `viewer` / `read` / `readonly` / `read-only` → `viewer`, anything else (`member`, empty) → `operator`. Per posture: -- **Signed in** (local users or OIDC): the user's role in the org; an owner +- **Signed in** (local users, OIDC or a reverse proxy): the user's role in the org; an owner is an admin everywhere. The bearer (`OBSERVOGRAM_API_TOKEN`) acts as an `operator` of its `X-Observogram-Org` (else the default org), never as an admin or owner. @@ -388,7 +578,11 @@ anything else (`member`, empty) → `operator`. Per posture: beyond loopback): the caller is `local`, an owner — every route, as before, but the identity API: on a loopback server it answers only a request sent straight to it, and beyond loopback it is closed (see - [The Identity API](#the-identity-api)). + [The Identity API](#the-identity-api)); `local` never sends a typed MCP + URL — it fetches from registered endpoints ([Fetch Live From MCP](#fetch-live-from-mcp)); + and a journey that notifies runs only from a request sent straight to a + loopback address, never on a server bound beyond loopback ([Saved + Journeys](#saved-journeys--repeatable-drift-checks)). Every authorization refusal carries `denied` — `auth` (401: sign in), `csrf`, `org` (not a member of that org), `role` or `posture` — and a @@ -402,11 +596,23 @@ coming from another site, or a sibling subdomain change — every identity-API request but a `GET`, and `POST /auth/signout-others` — needs `X-Observogram-CSRF: 1` in every posture, the open ones included, so a cross-site form cannot make one (the -bearer, which a cross-site page cannot send, never reaches one). +bearer, which a cross-site page cannot send, never reaches one); so does +every change to the org's MCP endpoint records (`/api/mcp-endpoints`), where +the server will send a read token of the org. Authorization is decided when a request reaches its route: a request already running when its user is disabled or demoted finishes; the user's next one is refused. +In the studio, Settings (Advanced → Settings, or the account menu) draws +what each role may do from the server's answer for the active org: every +member reads the environments and the MCP endpoints (a viewer an +endpoint's name and origin only), an operator edits the environments, an +admin the MCP endpoints, the members, the org's name and the audit, an owner +also the deployment's users, organisations and join role; a section or a +control the reader's rank cannot use is listed `aria-disabled` with the +sentence naming who can. The server still decides every write; a +refusal is shown as served. + **What a viewer can no longer do in the studio** (each answers with the server's text): Scan (a repo or GitHub), Draft from MCP and the MCP panel's live refresh, dropping or uploading a pack file (even only to view @@ -414,15 +620,23 @@ it: the upload registers it), Build (its preview computes on the server, and Save registers), Compare's retrofeed, journey Capture and Run, Deploy / Verify / Rollback, and RESET. Give such a member `operator` with `npm run orgs -- add-member --role operator`, or, as an -admin of the org, `PATCH /api/org/members/` with `{"role": "operator"}`. +admin of the org, Settings → Members (or `PATCH /api/org/members/` with +`{"role": "operator"}`). MCP write tokens are unrelated to the API token: they pass through per request and are never stored server-side. Userinfo, the fragment and query parameters named like credentials (`token`, `api_key`, `X-Amz-Signature`, …) are never kept in the live pack or a draft; put a token in the auth field — -never in the URL's path. Registered packs and the deploy -audit live in the `.observogram/` workspace (`OBSERVOGRAM_WORKSPACE` -relocates it). +never in the URL's path. Registered packs (one `packs/.pack.yaml` +each) and the deploy audit live in the `.observogram/` workspace +(`OBSERVOGRAM_WORKSPACE` relocates it); a pack's label, source, times and +the services it names are rows in the store (the `packs` and +`pack_services` tables). A `packs/index.json` left by a build before the +pack registry moved (0.5.0 and earlier) is read once, at the first start +of this build, then frozen in place: the server never writes it again and +only compares it at every start (see [Stale Import](#stale-import)); +`packc store export` rewrites it from the rows for an older build (see +[Upgrade And Roll Back](#upgrade-and-roll-back)). Two read routes answer without a session in every posture: `/healthz` (probes) and `GET /api/version` — version, build, commit, branch, dirty, @@ -469,7 +683,7 @@ emails go in the JSON body. | `GET` | `/api/admin/orgs` | owner | — | `defaultOrg` and every org, removed ones too, with its `members` count | | `POST` | `/api/admin/orgs` | owner | `{ id, name?, adopt? }` | a new org at `orgs//` (201), the caller its first admin; `"adopt": true` takes over a non-empty directory; a slug is never reused | | `DELETE` | `/api/admin/orgs/:id` | owner | — | a soft removal, never of the default org: the org is refused from its members' next request; its files stay (`packc store purge-org `, with the server stopped) | -| `GET` | `/api/admin/join-role` | owner | — | the recorded join role (`viewer`, `operator`, `admin` or `null`), `oidc`, `issuerKey` | +| `GET` | `/api/admin/join-role` | owner | — | the recorded join role (`viewer`, `operator`, `admin` or `null`), `oidc`, `issuerKey`, `mode` (`local`, `oidc` or `proxy`; behind a reverse proxy a `proxy` object says what rules first-sight roles there instead) | | `PUT` | `/api/admin/join-role` | owner | `{ role, confirm? }` | the default-org role of every IdP user created from now on; `null` or `"none"`: no automatic join | | `PATCH` | `/api/org` | admin | `{ name }` | renames the org (1–200 characters) | | `GET` | `/api/org/members` | admin | — | the org and its members: `userId`, `login`, `kind`, `name`, `email`, `role`, `disabled`, `since` | @@ -546,6 +760,15 @@ request writes none. and the bearer — are refused as a new local login, from the API and from `npm run users -- add`; the API also refuses the configured `OBSERVOGRAM_API_TOKEN_LABEL`. +- **Org ids that would share variables.** An org's variables are + `OBSERVOGRAM_ORG__`, each owned by the org whose prefix is the + longest match, removed orgs included, so `POST /api/admin/orgs` and + `npm run orgs -- create` refuse (409, nothing written; the same texts + from a shell) an id that would take another org's notify hooks + (`acme-notify` beside `acme`), one whose own hooks another org already + takes, and one that differs from another org's id only in `-` and `_` + (`a_b` beside `a-b`), naming the other org — an id that only starts alike + (`acme-notifications`, `acme-eu`) is created as before. - **Without sign-in** (the open postures, where the caller is `local`, an owner): on a loopback server the identity API answers only a request sent straight to it — `Host` `localhost`, `127.x.x.x` or `[::1]`, no @@ -570,6 +793,426 @@ keeps its expiry (signing out elsewhere never extends a session). It answers `{ ok, sessionEpoch }` and writes one `user.signout` row, the user its actor. +**In the studio: Settings → Members and Settings → Audit.** An admin of the +active org adds a member by login or by verified email (an existing member is +given the role: `oscar was already a member: operator → admin.`), changes a +role and removes a member; the last-admin rule is drawn before the server +says it (the last enabled admin's Remove and lower roles are `aria-disabled` +with the sentence naming the way out), and removing oneself reloads the +browser into the next org the user belongs to. Rename sets the org's name +(`PATCH /api/org`); the ORG chip follows. A member row carries the login, the +name and the role — never an email. The audit lists the org's rows to an +admin (`GET /api/audit`, `scope=org`) and the deployment's to an owner, +filtered by actor, kind, action, target and whole UTC days (`From` and +`Through`, sent as `since` and an `until` of the day after), older rows paged by `next`; a filter the +server refuses is its sentence, as served. + +**In the studio: Settings → Users, Organisations and Join role** (an owner's; +a signed-in member who is not one is told to ask one). New local user asks for no +password: the browser draws a temporary one (24 characters), creates the user +with it and sets it again at once (`POST /api/admin/users/:id/password`), so +the user must change it at first sign-in. The dialog shows it once — never in +the status line, a title, the browser's storage or the console — and Close, +leaving Settings or the page lets go of it. Reset password does the same for +a local user; disable, enable, sign out everywhere and make or revoke owner +each confirm first, and the last enabled owner's Disable and Revoke owner are +drawn `aria-disabled` with ` is the last enabled owner — make another +user an owner first`. Disabling or signing out oneself clears this browser's +traces of that login, then offers the sign-in. A user row carries no email. +On a server without sign-in, at `127.0.0.1`, the first local user is created +as the owner and arms sign-in for the next start without +`OBSERVOGRAM_AUTH=off`: the dialog says so, its password not forced to +change. New organisation makes the owner its first admin (Take over an +existing directory adopts `orgs//`) and offers Switch to it; Remove asks +for the id typed, says what cannot be undone, and shows the server's note. +**Act in ** reloads this browser into an org the owner is not a member +of — the way to rescue an org with no enabled admin left — with the ORG chip +reading ` — acting as owner`. Join role states the sign-in mode it +applies to first; admin needs a ticked box, the only body that carries +`confirm`. + +### Services, Environments And MCP Endpoints + +A **service record** is the org's own entry for one service, in the store +since slice 4a: a `slug` (the key the catalogue files services under — +lowercase letters, digits and `-`, derived from the name; fixed once +created, because packs link to a service by slug), a `name`, `owners`, a +`tier` (`tier-1`, `tier-2`, `tier-3`, or `null`: graded by the pack) and a +`description`. A service has **environments** (`production`, `staging`, …), +each with its own optional `tier`, free-form string `bindings` (a cluster, +a namespace, a region), named `endpoints` (http(s) links — a dashboard, a +runbook) and, optionally, the **MCP endpoint** it is checked through. An +MCP endpoint record is the org's named MCP server — `name`, `url` and +`readTokenEnv`, the name of the environment variable the server reads the +read token from ([Fetch Live From MCP](#fetch-live-from-mcp)); the +record holds no secret. Packs link to services: registering a pack (a scan, +a draft, an upload, a library register) creates the service rows it names — +its primary service and its members; a live aggregate pack, a snapshot of a +whole MCP backend, has no primary and names every service it discovered — +with an environment per name the pack declares, and links the pack to each +(`role` `primary` or `member`). The rows the register creates carry no tier +(`null`): a person sets one. A service deleted over the API stays deleted +across restarts and rehydrates (its environments and pack links go with it; +the packs stay registered); registering a pack that names it re-creates it, +by that caller (`service.create { via: 'register', pack }`). The rules live +once in [`server/service-admin.mjs`](server/service-admin.mjs), a sibling +of `identity-admin.mjs`, and every change writes its audit rows with the +caller as the actor. + +Operators manage services and environments; admins manage the MCP endpoint +records; every member reads all three. The org is the request's +(`X-Observogram-Org`, `?org=`): no path names an org, and an id of another +org is never found (404, or 400 for `mcpEndpointId`). + +| Method | Path | Who | Body | What it does | +|---|---|---|---|---| +| `GET` | `/api/services` | viewer | — | `services`, by slug, each with `environments` (their `mcpEndpoint` as `{ id, name, origin }` or `null`) and `packs` (`id`, `label`, `source`, `role`) | +| `POST` | `/api/services` | operator | `{ name, slug?, owners?, tier?, description? }` | a service record (201); `slug` defaults to the name's key; `owners` at most 50 names; a slug in use is 409 naming its id | +| `GET` | `/api/services/:id` | viewer | — | one service record with its environments and packs; 404 `no service ` | +| `PATCH` | `/api/services/:id` | operator | any of `name`, `owners`, `tier`, `description` | `changed` lists the fields that differed (none: no audit row); `slug` in the body is 400 — the slug is fixed, create a new service instead | +| `DELETE` | `/api/services/:id` | operator | — | `deleted`, with the counts of `environments`, `packLinks` and `waivers` removed with it | +| `GET` | `/api/services/:id/environments` | viewer | — | `service` (`id`, `slug`, `name`, `tier`) and its `environments` | +| `POST` | `/api/services/:id/environments` | operator | `{ name, tier?, bindings?, endpoints?, mcpEndpointId? }` | an environment (201); `bindings` at most 32 string values of 1–256 characters, `endpoints` at most 20 http(s) URLs by name, `mcpEndpointId` one of the org's MCP endpoints; a name in use is 409 | +| `GET` | `/api/environments/:id` | viewer | — | `environment` and its `service` (`id`, `slug`, `name`, `tier`); `effectiveTier` is the environment's tier, else the service's | +| `PATCH` | `/api/environments/:id` | operator | any of `name`, `tier`, `bindings`, `endpoints`, `mcpEndpointId` | `changed`; `"mcpEndpointId": null` unbinds | +| `DELETE` | `/api/environments/:id` | operator | — | `deleted` | +| `GET` | `/api/mcp-endpoints` | viewer | — | `endpoints`, by name: `id`, `name`, `origin`, `environments` (how many are checked through it), `createdAt`; `url` and `readTokenEnv` to an operator and above, `null` to a viewer; `policy`: what this reader may do — `typed` (send a typed `mcpUrl`) and `register` (register an endpoint), each `{ allowed, why, listed, origins }`, `why` the refusal's sentence, `listed`/`origins` the reader's own org's origin allowlist (`origins` `null`: any); `register.listedOnly`: only a loopback MCP or a listed origin may be registered, even without `readTokenEnv` (a list applies, or the server has no sign-in) | +| `POST` | `/api/mcp-endpoints` | admin | `{ name, url, readTokenEnv? }` | an MCP endpoint record (201), the admin's own view with `url` and `readTokenEnv`; a name in use is 409 | +| `PATCH` | `/api/mcp-endpoints/:id` | admin | any of `name`, `url`, `readTokenEnv` | `changed`; `"readTokenEnv": null` clears it; 404 `no MCP endpoint ` | +| `DELETE` | `/api/mcp-endpoints/:id` | admin | — | `deleted` and `unbound`: the ids of the environments that were checked through it (they stay, with no MCP endpoint) | + +Call them with a session cookie, the org header when the org is not your +default one, and `X-Observogram-CSRF: 1` on every change: + +```bash +# Sign in; the jar keeps the session cookie. +curl -s -c jar -H 'Accept: application/json' \ + --data-urlencode username=ada --data-urlencode 'password=' \ + http://127.0.0.1:8000/auth/login +# A tier-1 service in acme (201); the slug defaults to "checkout". +curl -s -b jar -H 'X-Observogram-CSRF: 1' -H 'X-Observogram-Org: acme' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Checkout","owners":["payments"],"tier":"tier-1"}' \ + http://127.0.0.1:8000/api/services +# As an admin of acme: the org's MCP endpoint, its read token read from +# OBSERVOGRAM_ORG_ACME_MCP_TOKEN on the server (201). +curl -s -b jar -H 'X-Observogram-CSRF: 1' -H 'X-Observogram-Org: acme' \ + -H 'Content-Type: application/json' \ + -d '{"name":"prod-otel","url":"https://otel-mcp.example.com/mcp","readTokenEnv":"OBSERVOGRAM_ORG_ACME_MCP_TOKEN"}' \ + http://127.0.0.1:8000/api/mcp-endpoints +# The service's production environment, checked through that endpoint (201). +curl -s -b jar -H 'X-Observogram-CSRF: 1' -H 'X-Observogram-Org: acme' \ + -H 'Content-Type: application/json' \ + -d '{"name":"production","bindings":{"cluster":"eu-1"},"endpoints":{"dashboard":"https://grafana.example.com/d/checkout"},"mcpEndpointId":1}' \ + http://127.0.0.1:8000/api/services/1/environments +``` + +A rule's refusal is 400 (bad input), 404 (no such service, environment or +endpoint) or 409 (a slug or name in use), and names the way out — `service +"checkout" exists (id 1) — PATCH /api/services/1 changes it`; a 403 is an +authorization denial, with `denied`. The three MCP endpoint changes take +the identity API's defences, because a record is where the server will +send the org's read token: `X-Observogram-CSRF: 1` in every posture (403 +`csrf` — `missing X-Observogram-CSRF: 1 — changes to the MCP endpoint API need +it in every posture, so a cross-site form cannot make them (the studio +sends it; with curl add -H 'X-Observogram-CSRF: 1')`), only a request sent +straight to a loopback server without sign-in, and closed on an exposed +server without sign-in (403 `posture`, as [the identity +API](#the-identity-api) is). Without sign-in only a loopback MCP or an +origin in the [origin allowlist](#fetch-live-from-mcp) may be registered, with +or without `readTokenEnv` (400: `on a server without sign-in, only a loopback +MCP or an origin listed in OBSERVOGRAM_MCP_ORIGINS may be registered — list + there, or sign in as an admin …`): the quick start against a +loopback MCP stays one step, and a remote demo MCP takes +`OBSERVOGRAM_MCP_ORIGINS=` or a first user +(`npm run users -- add `). The bearer (`OBSERVOGRAM_API_TOKEN`) is an +operator: it manages services and environments and reads the endpoints +with their URLs, and never changes an endpoint. + +- **The tier rule.** `GET /api/packs/:id/conformance` grades a registered + pack at its service record's tier when one is set — the environment's for + `?env=` when that environment has one, else the service's; the + pack's primary service is the record. `declaredTier` in the report is the + tier it was graded at, so `scorePercent`, `mustPercent` and each clause's + `applies` follow it; the report's `tier` object says where it came from: + `{ graded, pack, from: 'environment' | 'service' | 'pack', service: { id, + slug } | null, environment: { id, name } | null, mismatch }`. `tier.pack` + is the pack's own `metadata.bindings.criticality` (`tier-3` when it + declares none) and `tier.mismatch` says the record and the pack differ — + shown, never blocked: fix the record or the pack. A pack with no record + (a catalogue or example pack, a service whose rows carry no tier) is + graded at its own tier, `from: 'pack'`. +- **An endpoint picked by id.** `POST /api/refresh-live` and + `POST /api/draft-from-mcp` take `mcpEndpointId` in place of `mcpUrl` (a + typed `mcpUrl` there is an admin's — [Fetch Live From MCP](#fetch-live-from-mcp)); + the server uses the record's URL and, when the request sends no + `mcpAuth`, reads the read token from the record's `readTokenEnv` at + request time. The deploy and rollback routes take `mcpEndpointId` for the + URL only (a typed `mcpUrl` there is an admin's too): a write token stays + the request's `mcpAuth`, never a record's. + Sending both `mcpUrl` and `mcpEndpointId` is 400 (`send mcpUrl or + mcpEndpointId, not both`); see [Fetch Live From MCP](#fetch-live-from-mcp). +- **The read-token variable.** `readTokenEnv` must be + `OBSERVOGRAM_ORG__` — `ORG` the org id in upper case with `-` + as `_` (`acme` → `OBSERVOGRAM_ORG_ACME_`, `pay-eu` → + `OBSERVOGRAM_ORG_PAY_EU_`), `NAME` of `[A-Z0-9_]+` — and must be **this + org's**: the owning org of a name is the one whose prefix is the longest + match among every org, so `OBSERVOGRAM_ORG_ACME_EU_TOKEN` is `acme-eu`'s, + not `acme`'s. Why: without it an org admin — not an owner — could point + an endpoint at a URL they control and have the server send any variable + of its process there (`OBSERVOGRAM_SESSION_SECRET`, + `OBSERVOGRAM_API_TOKEN`, a cloud credential). The rule runs at the write + and again at each request that reads the variable (an org created later + can become the owner of a stored name; the request is then refused naming + `PATCH /api/mcp-endpoints/`). Pick a `NAME` that is not another + org's id followed by `_`. A name with the shape of any org's notify hook + (`OBSERVOGRAM_ORG__NOTIFY__URL` or `_TOKEN`, [Saved + Journeys](#saved-journeys--repeatable-drift-checks)) is refused too, since + a hook's variables go only to its own webhook: at the write (400, naming + another variable of the org to use), and for an endpoint registered before + that rule where the token would be read, before it is read (400, naming + `PATCH /api/mcp-endpoints/`). The variable is set on the server — the + k8s studio Deployment's `env`, from a Secret; never on the journey CronJob + ([`deploy/k8s/README.md`](deploy/k8s/README.md)) — and its value is never + logged, returned or stored. +- **The MCP URL carries no credential.** `url` is refused with userinfo, a + fragment, or a query parameter whose *name* looks like a credential + (`token`, `api_key`, `sig`, …; `signal`, `design`, `author` pass — the + word rule of `tools/lib/mcp-url-safety.mjs`): `observogram store: an MCP + endpoint URL may not carry credentials in its query — the parameter(s) + "token" look like credentials; remove them and name an env var in + readTokenEnv`, naming the parameters and never the URL. An environment's + `endpoints` follow the same rule (a token goes in no link). +- **What a viewer sees.** Every member reads the services, the environments + with their `bindings` and `endpoints` in full (they are links a viewer + opens — never put a token in one), and each environment's MCP endpoint as + `{ id, name, origin }`. The MCP URL itself and the variable's name go to + operators and above (`GET /api/mcp-endpoints`); the token's value to + nobody. +- **In the studio** (STORE_PLAN slice 6a). The home reads the table: one card + per record — the tier (`graded by the pack` for `null`), the owners, the + packs linked, and per environment the verdict of the newest primary pack + graded at the record's tier (a base grade named as such when the pack + declares no overlay for that environment; a report resting on placeholders + is never plain green). A card opens the service page: the environments as + tabs with their MCP endpoint (name and origin only) and verdict, the packs + linked with the newest primary marked current, and Discover · Diagnose · + Remediate · Build opening bound to the service and the environment; the + header SERVICE selector reads the same table through the same resolver. + Operators edit the record from the page (name, owners, tier, description + over `PATCH /api/services/:id` — the slug stays, the verdict is re-read + after a tier save). Build ends by writing the row: the register links the + pack by slug and creates its environment, then the tier and owners typed on + DEFINE go onto the record where it has none — a tier a person set is never + overwritten (the mismatch with the pack's own tier is shown); a DEFINE name + that yields another slug is said on DEFINE first, and such a pack registers a + new service with the origin untouched. A viewer opens every page and reads + every verdict; the Build card, the home's sources and Edit are drawn disabled + with the reason (`needs the operator role in — yours is viewer`); a + browser beside `OBSERVOGRAM_API_TOKEN` without sign-in reads as a viewer with + the token reason. One org switcher (the chip in the OBSERVA bar, its title + naming the effective role); the studio's persisted state is one login's in + one org, so a draft started in one org is not in another. A signed-in user + in no org sees the server's refusal and the account menu. +- **In the studio: Settings → Environments and Settings → MCP endpoints.** + Operators add, edit (name, tier, bindings, links, the MCP endpoint) and + delete environments in one editor, opened from Settings or from the service + page (Add environment, Edit environment); a save sends only what changed, + so a tier save neither resends nor clears the binding. Admins register, + edit and delete the org's MCP endpoint records: the URL, and the token as + the NAME of a variable under the org's prefix + (`OBSERVOGRAM_ORG__`), never its value; the server's refusal + of a credential in the URL or of another org's variable is shown as served. + A viewer sees an endpoint's name and origin only. The MCP + pickers — the refresh panel, the home's source card, the draft panel and + the deploy modal — list the org's endpoints first, and keep a typed URL + for a reader the server lets type one (an admin; `policy.typed` of + `GET /api/mcp-endpoints`): for everyone else — an operator, and every + caller without sign-in — they are list-only, no sentence offers typing a + URL, a hidden field's leftover is never sent, and an empty list names the + way in for that reader (an admin registers one in Settings → MCP + endpoints; the button when the server says this reader may register). + Without sign-in and with no endpoint, the home's Connect is **Register and + connect**: the typed URL is registered as the org's endpoint (named by its + host) and tested by its id, offered only for a loopback MCP or a listed + origin — any other is refused beside the button, nothing sent. A chosen + endpoint is sent as `mcpEndpointId` (never with `mcpUrl`), rollback and + verify follow it, and a write re-reads the endpoint before it sends: an + endpoint moved or deleted since the list was drawn sends nothing and says + so. A deploy profile remembers its endpoint per org; a profile with a + typed URL, opened by a reader who may not type one, says so and sends the + list's endpoint. + +### The Audit + +Every change a principal makes through the server is one row in the store's +`audit` table ([docs/STORE_PLAN.md](docs/STORE_PLAN.md) §5): an action +`.` — who (`actor`), when (`at`), in which org (`orgId`; `null` +for a deployment-level change: a user, an org, an owner grant, a store +import), on what (`targetKind`, `targetId`) and a small, bounded `detail` +(it must serialize to at most 8192 characters; every free-text field is cut +to 200). The table is append-only — a trigger refuses every `UPDATE` and +`DELETE` (`audit is append-only`) — and no row carries a secret, an email, +or, in the deploy, journey and live rows, a URL beyond its origin. The route +table ([`server/route-table.mjs`](server/route-table.mjs)) says per route +which actions a successful call writes; nothing is implied, and a refused +request writes no row. + +**The actor** is the principal's stable identifier: a user's `login` (a +local username, or `#` for an OIDC user — never the email, +an unverified claim the IdP re-syncs), the bearer's label +(`OBSERVOGRAM_API_TOKEN_LABEL`, default `token`), `local` on a server +without sign-in; `system` for a join made outside a route (`user.jit`, +`membership.jit`, `owner.bootstrap`, `owner.first-local-user`) and `cli` +for `npm run users` / `npm run orgs`. **The same actor is stamped in +`deploys.jsonl`**: a new line names the login where it named the email (an +OIDC deployer who was `ada@acme.test` is `https://idp.example/realms/acme#9a3e0f`; +a local user with an email on her row is `ada`), so every viewer of +`GET /api/deploys` and of the Neuron evidence string sees logins. Old lines +keep their actor; the bearer's label and `local` are unchanged. Where a +display name is wanted, the `users` row has `email` and `name` by `login` +(`GET /api/admin/users` for owners, the member list for admins). + +**What an org admin sees** (`GET /api/audit`): every row of their org, +whoever the actor — including an owner who acted in the org without being +a member, the bearer, `system`. An admin cannot list the deployment's +users; the audit does show them the login of every owner who ever changed +their org, because who changed your org is yours to know. A deployment row +(`orgId: null`) is an owner's only. + +**The verdict rows** (GAP batch 2; [Record Verdicts](#record-verdicts)) +are table rows like the services': written in the transaction of the +change, by the repository. `verdict.set` on target `artefact` +`/` with `{ pack, artefact, family, from, to, reason }` +(`from` null for a first record; the reason cut to 200); `verdict.clear` +with `{ pack, artefact, from }`; `verdict.carry` on target `pack` (the new +pack id) with `{ from, kept, dropped, droppedCount }` when a re-upload under +the same label replaced a pack that held verdicts — written by the register +routes (`POST /api/validate`, `/api/crawl`, `/api/crawl-github`, +`/api/draft-from-mcp`, `/api/library/register`) between `pack.register` and +the link rows, and only then. A verdict cascades with its pack (an eviction, +`DELETE /api/uploads`, the rehydrate's prune) without a row of its own. + +**The waiver rows** (GAP batch 2; [Waive A Conformance +Finding](#waive-a-conformance-finding)) are table rows too: `waiver.create` +on target `waiver` `` with `{ service, ruleId, artefactId, expiresAt, +reason }` (the service's slug; the reason cut to 200), `waiver.revoke` with +`{ service, ruleId, artefactId, reason }` (the revoke's reason). A waiver +cascades with its service (`DELETE /api/services/:id` counts them in its +`service.delete` detail as `waivers`) without a row of its own. + +**The file-first rows.** The deploy routes, the journey capture and run, +and the live refresh change a file of the org's, not a table, so their row +is written after the file ([`server/audit-after.mjs`](server/audit-after.mjs)), +in a transaction of its own. `deploys.jsonl` stays the deploy file of +record, keyed by the `deployId` the row names: the row carries counts and +the MCP **origin**, never the URL, an item's error or a tool name. The +ping changes nothing of the org's; its one row — for a typed URL only — is +written the same way, after the answer is known. + +| Route | Action | `targetId` | `detail` | +|---|---|---|---| +| `POST /api/packs/:id/deploy/:target` | `deploy.run` | the new `deployId` | `{ pack: { id, version }, env, target: { product, version, folder }, mode, dryRun, origin, mcpEndpoint: { id, name } \| null, items, ok, failed, tookMs }` — on a 502 too (`ok: 0, failed: 1`), as the file records it | +| `POST /api/packs/:id/deploy-bulk` | `deploy.bulk` | the `deployId` | the same, plus `snapshot` (the pre-deploy snapshot's status) | +| `POST /api/deploys/:deployId/rollback` | `deploy.rollback` | the rollback's own `deployId` | `{ rollbackOf, pack, env, dryRun, origin, mcpEndpoint, items, ok, failed, manual, tookMs }` | +| `POST /api/deploys/:deployId/verify` | `deploy.verify` | the verified `deployId` | `{ outcome, alignment, attempts }` (`outcome` cut to 100 characters; the summary and transitions stay in the line) | +| `POST /api/journeys/capture` | `journey.capture` | the journey name | `{ packA, packB, live, env, service, scopeMode }` — the two pack ids and whether Pack B was saved as a live `mcp:` source, never the paths or the MCP URL the file holds; with a `notify`, also `notify: { urlEnv, authEnv }` — the names of the org's notify hook variables it chose (`authEnv` `null` without a token), never a value | +| `POST /api/journeys/:name/run` | `journey.run` | the journey name | `{ startedAt, outcome, alignmentPct, gradeScore, gradePass, breaches, tookMs }` — **one row per attempt past the 404** (and past a refused Pack B MCP target, which writes none): a run that fails from the studio leaves a row with `outcome: "error"`, or `"vantage-lost"` when a live source lost its vantage (the engine wrote a run record and may have notified), the four record fields `null`; never the error's message. A journey with `notify` adds `notify: { urlEnv, status, attempts }` — the hook's variable name, `sent` \| `skipped` \| `failed` (`null` when no record exists) and how many requests left the server (0 when none) — never any part of the hook's URL. A notify refused before the run (400) or by the posture (403) writes no row | +| `POST /api/refresh-live` | `live.refresh` | the MCP origin | `{ mcpEndpoint, refreshedAt, servicesDiscovered, toolsFailed }` (counts) | +| `POST /api/mcp/ping` | `live.ping` | the MCP origin | `{ verdict, typed: true }` — only when the caller typed the URL (an admin's privilege); a ping by `mcpEndpointId` writes no row | +| `POST /api/mcp-settings/submit` | `live.mcp-settings` | the MCP origin | `{ op: configure \| action:, path, endpointId, typed, status, fields: [names], acks: [rule indexes] }` — the opt-in pass-through only (`OBSERVOGRAM_MCP_ADMIN_PROXY=1`), once a request left for the MCP server (`status` null when no answer came); never a value. A settings request the browser sends straight to the MCP server leaves no studio row: the MCP server audits its own | +| `POST /api/mcp/jobs` (and its `…/cancel`) | `live.fetch` | the MCP origin | `{ kind, outcome: done \| failed \| cancelled, typed, mcpEndpoint, jobId, packId, stages: { done, skipped, failed }, gaps: [stage ids] }` — one row when the job ends, whether or not it registered a pack (whose own rows come first) | + +When one of the two writes fails: the operation stands. A row the store +refused (a blocked insert, a `detail` over 8192 characters) puts +`auditError` with the store's message on the response and one line on +stderr (`[deploy-bulk] audit row failed: … (the operation stands; +deploy.bulk dep_… is not on the record)`); a line that could not be appended (disk +full, `deploys.jsonl` a directory) still gets its row, flagged +`detail.fileError: true`, so the record of a deploy never vanishes with the +file — the verify's 500 carries its flagged row the same way. A request +refused before the MCP was contacted (400, 404, 409, 412) writes neither; +a capture, run or refresh whose file write threw writes no row (nothing of +the org's changed), the run's `vantage-lost` path excepted. CLI runs +(`packc journey run`, the CronJob) are not audited: that CLI never opens the +database (`packc store audit`, below, opens it to read and is not a run). + +**Reading it — `GET /api/audit`** (`admin`). An org admin reads the rows of +the request's org (`X-Observogram-Org`, `?org=`): `scope=org`, the only +scope an admin may ask for. An owner reads the deployment's: `scope=all` +(the default for an owner: every org's and the deployment's rows in one +sequence), `scope=org` (the request's org — the org selector picks it, as +everywhere; `?org=` is never a filter) or `scope=deployment` (the rows with +no org). An admin asking `scope=all` or `scope=deployment` is refused, 400: +`the deployment's audit (scope=deployment, scope=all) is an owner's: as an +admin of org 'acme' you read its rows (scope=org, the default) — drop scope, +or ask an owner`. The filters, each optional, ANDed: `actor` (exact — a +login, the bearer's label, `local`, `system`), `action` (`.`), +`kind` (every action of a kind: `deploy`, `pack`, `mcp_endpoint`, …), +`targetKind`, `targetId` (a `deployId`, a journey name, a pack id, a +login), `since` / `until` (`2026-10-04` or `2026-10-04T09:00:00Z`; +`at >= since`, `at < until`), `limit` (1–500, default 100). Rows come +newest first; `next` is the `seq` of the page's last row when another page +exists, else `null` — no more, never "try again"; send it back as +`before=` for the next page, with the same filters. Every refusal is a 400 +whose text names the way out (`limit must be an integer from 1 to 500`, +`since must be a date or a UTC time: 2026-10-04 or 2026-10-04T09:00:00Z`, +`scope is org, deployment or all`); an empty value is "not given"; unknown +parameters are ignored. The response is `{ ok, scope, org, limit, rows, +next }` — `org` the request's org for `scope=org`, `null` otherwise — and +each row `{ seq, at, orgId, actor, action, targetKind, targetId, detail }`. + +```bash +# an admin of acme: the org's deploy rows since a date, 50 a page +curl -sS -b cookies.txt -H 'X-Observogram-Org: acme' \ + 'http://127.0.0.1:8000/api/audit?kind=deploy&since=2026-09-28&limit=50' +# the next page: the previous answer's next as before= +curl -sS -b cookies.txt -H 'X-Observogram-Org: acme' \ + 'http://127.0.0.1:8000/api/audit?kind=deploy&since=2026-09-28&limit=50&before=318' +# an owner: the deployment-level rows only (users, orgs, owners, imports) +curl -sS -b cookies.txt 'http://127.0.0.1:8000/api/audit?scope=deployment' +``` + +The listing holds every login and every MCP origin of the deployment, so +the route is closed where the member and user lists are: on an exposed +server without sign-in (403 `posture`, `the audit API is closed on a server +bound to …`) and, on a loopback server without sign-in, answered only to a +request sent straight to a loopback address (`on a server without sign-in +the audit API answers only requests sent straight to a loopback address …`), +as [the identity API](#the-identity-api) is. Below `admin` it is the +guard's 403 `role`; the bearer is an operator and cannot read it. The read +writes no row. + +**From a shell — `packc store audit`.** The bearer is an operator and +cannot read `GET /api/audit`, so a shell on the pod or a CI job lists the +audit from the database file instead: `packc store audit` (the store is +`OBSERVOGRAM_DB`, else `/observogram.db`; WAL makes the read +safe while the server runs). It is read-only — it writes no row — and it +is an owner's view: every org's and the deployment's rows by default, +`--org ` one org's, `--deployment` the rows with no org (`--all` says +the default). The filters are the route's as flags — `--actor`, `--action`, +`--kind`, `--target-kind`, `--target`, `--since`, `--until`, `--limit` +(1–500, default 100), `--before` — with the same rule and the same refusal +texts spelled with the flags (`--limit must be an integer from 1 to 500`). +One JSON row per line on stdout, newest first, the row the API serves; +the `store: ` line and, when more rows exist, `next: ` go to +stderr, so stdout pipes into `jq` as it is: + +```bash +# acme's deploy rows since a date, 50 a page; the next page by the stderr line +packc store audit --org acme --kind deploy --since 2026-09-28 --limit 50 +packc store audit --org acme --kind deploy --since 2026-09-28 --limit 50 --before 318 +# the deployment-level rows (users, orgs, owners, imports), as JSON lines +packc store audit --deployment | jq -c '[.seq, .action, .actor, .targetId]' +``` + +Like its siblings it refuses `OBSERVOGRAM_DB=:memory:` and a workspace +whose `users.json` / `orgs.json` the server has not imported yet, and it +never creates a database (`no database at … — nothing to list`). On a +loopback server without sign-in, the refusal of an indirect `GET /api/audit` +names it as the way out from this machine (`…, or list it from this machine +with packc store audit`). + ### Run In Docker Or Kubernetes The whole app is one Express process, so the container story is one image: @@ -584,7 +1227,9 @@ The image binds `0.0.0.0`, so it needs a seeded sign-in (or `OBSERVOGRAM_API_TOKEN`) to start — see Security Posture above. Its workspace is `/app/.observogram` (owned by the `node` user the container runs as); mount a volume there, or point `OBSERVOGRAM_WORKSPACE` at one, to keep users -and packs across containers. +and packs across containers. The image carries Observogram's `LICENSE` beside +`package.json`, and its `node_modules` each installed package's own licence +file. Kubernetes manifests (Deployment + Service + Ingress + the `store` PVC, applied with Kustomize) live in [`deploy/k8s/`](deploy/k8s/README.md). The @@ -634,21 +1279,45 @@ and `npm run build:stamp` refuses it (exit 2) until the history is there — ### Scan A Repo ```bash -npm run crawl -- path/to/service-repo --name krystalinex-core --env prod > repo.pack.yaml +npm run crawl -- path/to/service-repo --name payments-api --env prod > repo.pack.yaml npm run validate-pack -- repo.pack.yaml ``` The crawler reads source files such as: -- Prometheus rule files +- Prometheus rule files (recording rules, burn-rate and operational alerts) +- Grafana unified-alerting provisioning YAML and Loki rule files - Grafana dashboard JSON - Alertmanager config - OTel Collector config - Helm and Kubernetes manifests - Docker Compose files -It emits a canonical v1.3 pack plus crawler annotations describing what was -scanned and what was inferred. +It emits a canonical v1.4 pack plus crawler annotations describing what was +scanned and what was inferred. A fresh crawl validates (exit 0) and needs no +upconversion; `npm run pack-conformance -- repo.pack.yaml` lists what it had to +stub. Names (`--name`, `--env`, `--owners`) must be spec Slugs — 2-64 lowercase +letters, digits, `_` or `-`, starting with a letter — and `--criticality` / +`--binding` must be spec values: a flag the spec cannot hold is refused before +the crawl with exit 2, the rule in the message and nothing on stdout. Without +`--name` the folder's name is normalized to a Slug and the original kept in +`crawler.nameNormalizedFrom`. Exit 3 means the pack failed its own schema — a +crawler bug, report it. Recording rules not named `::` +cannot be declared in `spec.queries.recording_rules`; they are recorded in +`crawler.omitted.recording_rules` (their expressions still feed the metric +inventory). Every value the crawler invents — the stub SLI/SLO, an assumed +port, an Alertmanager address the config does not state, the default owner, +the OTel SDK defaults — carries a `crawler.scaffold.` annotation with +the reason; the conformance tool turns those into rows +([`docs/DOWNSTREAM.md`](docs/DOWNSTREAM.md) §12). Every alert rule is kept: a rule whose +expression references a recorded SLO series is a burn-rate alert +(`spec.policy.burn_rate_alerts`); every other rule — a pod restarting, a pool +saturated, a certificate expiring — is an operational alert and is declared in +`spec.alerting.rules` (spec 1.4) under its exact name, with its expression, +wait, severity, labels, engine (`prometheus`, `grafana`, `loki`) and the file +and group it was read from. The same name is what a live Grafana or ruler +listing is reconciled on, so the repository's rule and the running rule pair +in Compare. ### Fetch Live From MCP @@ -665,6 +1334,73 @@ reads each org's own live pack, `/live/production-live.pack.yaml` refresh; `OUTPUT=/live/production-live.pack.yaml npm run fetch-live` feeds it from the CLI. Both keep the same safe URL: no userinfo, fragment or credential query parameter (`MCP_AUTH` is the place for a token). +Behind a gateway with its own auth, extra headers, a proxy or a private CA, +`OBSERVOGRAM_TRANSPORT_HOOK=` names a module whose +`prepareRequest({ url, headers })` (sync or async) is applied to every MCP +request the CLI, the recorder, the live probes, a journey and the studio +server make, and whose optional `fetchImpl` replaces the fetcher; the URL it +returns passes the same http(s)/local-address policy as the caller's. A hook +that fails to load or breaks its contract is a hard failure — exit 1, no pack +written; the server refuses to start on a load failure — while network +failures through it stay ordinary probe failures. Unset, nothing changes +(`docs/MCP_INTEGRATION.md`, "Transport hook"). + +**A typed MCP URL is an admin's.** Supplying the URL the server fetches is +the privilege, not which endpoint it uses: a typed `mcpUrl` in +`POST /api/draft-from-mcp`, `POST /api/refresh-live` and the deploy routes +(`POST /api/packs/:id/deploy/:target`, `…/deploy-bulk`, +`POST /api/deploys/:deployId/rollback`, which send the caller's write token) +needs the admin role in the org (an owner included). An operator, the bearer token and every caller +without sign-in — the open postures' `local`, even on loopback — fetch from the +org's registered endpoints only (`mcpEndpointId`), and are told the way that +works for them (403 `role` or `posture`: an admin registers the endpoint in +Settings → MCP endpoints; in the token posture, sign-in is armed first with +`npm run users -- add `). A typed URL may not carry `user:password` (400: +send the token as `mcpAuth`). `GET /api/mcp-endpoints` says what the reader may +do (`policy`). An operator's CI deploy (the bearer is an operator) registers +its target once and sends `mcpEndpointId`. + +The studio's `POST /api/refresh-live` and `POST /api/draft-from-mcp` take +either `mcpUrl` (an admin's, with an optional `mcpAuth`) or `mcpEndpointId`: +one of the org's named MCP endpoints (`GET /api/mcp-endpoints`; an admin +registers them with `POST /api/mcp-endpoints`). With an id the server uses the +record's URL, and when the request sends no `mcpAuth` it reads the endpoint's +read token from the variable the record names, `OBSERVOGRAM_ORG__` +(`ORG` = the org id in upper case, `-` → `_`; for example +`OBSERVOGRAM_ORG_DEFAULT_MCP_TOKEN`), set in the server's own environment (the +k8s Deployment's `env`, from a Secret). An admin may name only their org's +variables; the owning org is the one whose prefix is the longest match, so +`OBSERVOGRAM_ORG_ACME_EU_X` is `acme-eu`'s, not `acme`'s, and the check runs +again at each request. A variable that is not set is a 400 naming it, before +anything is fetched. The response says which record was used (`mcpEndpoint: +{ id, name }`, or `null` for a URL); the live pack and the draft keep the safe +URL and never the token. The deploy and rollback routes take `mcpEndpointId` +for the URL only — a write token stays the request's `mcpAuth`. + +**The MCP origin allowlist.** Every MCP target the server reaches — a typed +`mcpUrl`, a registered endpoint at each use, and an endpoint when it is +registered or its `url` or `readTokenEnv` changes — meets an origin rule, +whatever the caller's role. `OBSERVOGRAM_MCP_ORIGINS` lists origins for every +org and `OBSERVOGRAM_ORG__MCP_ORIGINS` for one org (read only by the org +that owns the name, the same longest-prefix rule); an org's list is the union +of the two, so a deployment that keeps tenants apart leaves the global list +empty. Entries are comma-separated origins — `https://mcp.example.com`, +`http://mcp.lab:3001`: no path, query, fragment, credentials or `*` in a host; +a rejected entry is named once on stderr and ignored, and a list with no +accepted entry allows nothing. A lone `*` allows every origin. Loopback +(`localhost`, `127.0.0.0/8`, `[::1]`) always passes (`OBSERVOGRAM_ALLOW_LOCAL_MCP=0` +still closes it). With a list set, every other origin must be in it. **With +none set, no credential leaves for an origin other than loopback** — not an +endpoint's server-held token, not the caller's `mcpAuth` (every deploy sends +one), not a credential in a typed URL, and nothing at all while a transport +hook is loaded, since the hook may attach its own; a typed URL without a +credential reaches only an origin one of the org's endpoints uses, and an +endpoint without `readTokenEnv` may be registered by a signed-in admin and used +anywhere (without sign-in, only a loopback or listed origin may be registered +at all). A refusal +is 403 `origin` at a fetch (400 at registration) and names the origin only and +the variable to add it to. An install that sends a credential to a remote MCP +sets `OBSERVOGRAM_MCP_ORIGINS` before upgrading. When the MCP exposes `metrics_query`, the fetch also samples the observability stack's own self-metrics (scrape, ruler, notify, tsdb, collector, dashboards, synthetic, logs, traces) as point-in-time signals — never verdicts, stamps or @@ -677,6 +1413,88 @@ pinned versions in Docker (`docker/stack.compose.yaml`; skips without Docker). See [`docs/MCP_INTEGRATION.md`](docs/MCP_INTEGRATION.md) for the live fetch and write-back contract. +### Test An MCP Connection And Build A Live Pack + +The studio's **Live MCP connection** panel and **new from live** panel do +this in three steps — test the connection, choose **Draft** (a scaffold from +MCP discovery) or **Snapshot** (an inventory of what is actually deployed), +then follow the gate log — and the same routes serve a script (rebadge batch +3; docs/DOWNSTREAM.md §15 has every shape). Without sign-in the live MCP API +answers only a request sent straight to a loopback address, and every POST +takes `X-Observogram-CSRF: 1` (the bearer is exempt). Below, `$H` is your +auth (`-H "Authorization: Bearer $OBSERVOGRAM_API_TOKEN"` or a session +cookie) and endpoint 3 is one of the org's registered MCP endpoints: + +```bash +# 1. Test the connection — writes nothing +curl -s -X POST http://127.0.0.1:8000/api/mcp/ping $H \ + -H 'Content-Type: application/json' -H 'X-Observogram-CSRF: 1' \ + -d '{"mcpEndpointId":3}' +# → { "verdict": "connected", "tools": { "capabilities": { … } }, "read": { … }, "checked": [ … ], "notChecked": [ … ] } + +# 2. Start a snapshot (or "kind":"draft") — 202 at once +curl -s -X POST http://127.0.0.1:8000/api/mcp/jobs $H \ + -H 'Content-Type: application/json' -H 'X-Observogram-CSRF: 1' \ + -d '{"kind":"snapshot","mcpEndpointId":3,"scope":{"metricPrefixes":["payments_"]},"label":"Payments prod"}' +# → { "ok": true, "job": { "id": "", "state": "running", … }, "poll": "/api/mcp/jobs/" } + +# 3. Poll the gate log; send back "next" as since +curl -s "http://127.0.0.1:8000/api/mcp/jobs/?since=0" $H +# → { "job": { "state": "running" }, "stages": [ { "seq": 1, "stage": "connect", "state": "done", … } ], "next": 1 } +# … until job.state is done (result.registered.id is the new pack), failed or cancelled + +# Stop it +curl -s -X POST http://127.0.0.1:8000/api/mcp/jobs//cancel $H -H 'X-Observogram-CSRF: 1' +``` + +The ping runs `initialize`, the whole `tools/list` and one cheap read within +10 s and says what it checked and what it did not. A job is its starter's +(another member's or org's, an expired or a lost one is a 404 `{ gone: true }`); +one runs per org, four per server; jobs live in the server's memory, so a +restart loses them (a pack already registered stays). A snapshot is labelled +`snapshot` in the pickers and the catalogue (`live: 'snapshot'`), a draft +`scaffold`; Compare's pack header says which. A typed `mcpUrl` in place of +`mcpEndpointId` is an admin's, under the origin allowlist ([Fetch Live From +MCP](#fetch-live-from-mcp)). + +**The snapshot scope** (inert when unset; a request's `scope` replaces it +whole; the draft and the refresh never read it): + +| Variable | Meaning | +|---|---| +| `OBSERVOGRAM_SNAPSHOT_METRIC_PREFIXES` | comma-separated metric-name prefixes a snapshot keeps (`payments_,checkout_`) | +| `OBSERVOGRAM_SNAPSHOT_FOLDER_UIDS` | comma-separated Grafana folder uids for dashboards and, when every rule names one, alert rules | +| `OBSERVOGRAM_SNAPSHOT_DATASOURCE_UID` | a datasource uid — named in the gate log as not applied while no advertised tool takes one | +| `OBSERVOGRAM_ORG__SNAPSHOT_METRIC_PREFIXES`, `…_FOLDER_UIDS`, `…_DATASOURCE_UID` | the same for one org (read only by the org that owns the name); each field overrides the deployment's | +| `OBSERVOGRAM_MCP_ORIGINS`, `OBSERVOGRAM_ORG__MCP_ORIGINS` | the MCP origin allowlist, unioned ([Fetch Live From MCP](#fetch-live-from-mcp)) | + +A variable that does not parse is a 400 naming it when a snapshot starts, and +`GET /api/mcp/jobs` lists it under `scope.errors`. + +**Configuring the MCP server itself vs configuring the studio's connection +to it.** The panel's URL, registered endpoint and token are the *studio's +connection* to the MCP server — what the studio server uses to read from it. +The panel's **Server settings…** button configures the *MCP server's own* +runtime settings — the backend base URL, user and secret it reads its backend +with, its API key — in a modal of this page (rebadge batch 4). By default +your browser sends them straight to the MCP server, which must publish a +settings description at `/admin/schema` (or the modal offers +a generic form), answer CORS for the studio's origin and authenticate the +request; the studio keeps none of the values. After a verified configure the +panel's connection test runs, and **Open the live panel** leads to a Snapshot +with nothing but this page. The button is enabled for whoever may register an +MCP endpoint (an admin or owner, or the open posture's caller on a direct +loopback request). The contract for MCP server authors, and what the studio +cannot enforce, is [docs/MCP_INTEGRATION.md](docs/MCP_INTEGRATION.md), +"Server settings (admin configuration)"; a downstream's migration off a +separate configuration page is docs/DOWNSTREAM.md §16. + +| Variable | Meaning | +|---|---| +| `OBSERVOGRAM_MCP_SETTINGS_POLICY` | a settings-policy file (JSON, strict) read once at start — rules that warn on a settings value and require an acknowledgement before the send, and the generic form's prefill; an unreadable or invalid file refuses the start; unset, inert | +| `OBSERVOGRAM_MCP_ADMIN_PROXY` | `1` passes the modal's settings through the studio server (`POST /api/mcp-settings/describe`, `/submit`) for an MCP server the browser cannot reach; off by default, read per request | +| `OBSERVOGRAM_MCP_ORIGINS`, `OBSERVOGRAM_ORG__MCP_ORIGINS` | also the remote MCP origins the modal may send settings to: settings carry a credential, so they go only to loopback or a listed https origin | + ### Validate Or Upload A Pack ```bash @@ -687,6 +1505,343 @@ The studio also accepts drag-and-drop or file picker upload. Uploaded, crawled, and MCP-drafted packs are registered in memory and become addressable through the same `/api/packs/:id/*` endpoints as catalog packs. +### Record Verdicts + +A verdict is a reviewer's record on one artefact of a registered pack — +`trusted`, `suspect` or `failed`, with a reason, who and when — kept in the +store beside the pack's row ([docs/ADAPTER.md](docs/ADAPTER.md), "Verdicts — +a reviewer's record per artefact"). It is a trust record, not a scorer +input: nothing sums verdicts into the conformance score or the diagnostic +grade, and `unreviewed` is the absence of a record. The artefact is named by +the adapter's positional id (`SLI-01`, `ALR-02`; `GET /api/packs/:id` lists +them), which is frozen within a pack id because the id is a content hash; a +re-upload of the same content keeps the verdicts, and a re-upload under the +same label with changed content carries each verdict onto the new pack's +artefact when the artefact's behavioural identity is unchanged (`carriedFrom` +names the old pack; the rest are dropped and the `verdict.carry` audit row +counts them). A catalogue or example pack holds no verdicts — register it +first (`POST /api/validate`) and record on the registered id. + +```bash +# record (operator): the body is { status, reason? } +curl -sS -X PUT -b cookies.txt -H 'X-Observogram-CSRF: 1' -H 'Content-Type: application/json' \ + -d '{"status":"suspect","reason":"the window is shorter than the SLO period"}' \ + http://127.0.0.1:8000/api/packs/uploaded-payment-service-1a2b3c4d/verdicts/SLO-01 +# read (viewer): the document, every artefact counted in summary +curl -sS -b cookies.txt http://127.0.0.1:8000/api/packs/uploaded-payment-service-1a2b3c4d/verdicts +# clear (operator) +curl -sS -X DELETE -b cookies.txt -H 'X-Observogram-CSRF: 1' \ + http://127.0.0.1:8000/api/packs/uploaded-payment-service-1a2b3c4d/verdicts/SLO-01 +``` + +In the studio, Discover shows a verdict as a badge on the board and a chip +on the row, the Refine control filters a layer by verdict, and the drawer's +Verdict section records one (operators and admins; everyone in the open +postures — beside `OBSERVOGRAM_API_TOKEN` without sign-in the browser is a +viewer, and the section names the bearer route for CI instead of a form the +API would refuse). The `actor` is the audit actor — a login or the token +label, never an email. `GET /api/packs/:id/export.zip` adds `verdicts.json` (the +GET document) after the compiled artefacts — only while the pack has a +verdict, so a pack without one exports exactly what it did before. + +### Waive A Conformance Finding + +A waiver is a time-boxed, reasoned suppression of one conformance finding: +one rubric clause (`GET /api/maturity-rubric` lists the ids) and, for the +four per-item clauses (`L1.MUST.sli_covered_by_slo`, +`L3.MUST.recording_rule_per_slo`, `L4.MUST.multi_window_burn_rate`, +`L5.MUST.tier1_chaos_for_each_slo`), optionally one canonical symbol of it — +`slos.` or `slis.`, the adapter's `defines` vocabulary, never a +JSONPath ([docs/CONFORMANCE.md](docs/CONFORMANCE.md), "Waivers"). It lives on +the **service record** the pack is primarily linked to, so a re-upload of +the service's pack keeps it; a catalogue pack has no service and no waiver. +A waiver never rewrites the rubric: `GET /api/packs/:id/conformance` keeps +the engine's numbers and, while the service holds an open waiver, gains a +`waivers` block — the service, the counts, per clause `waived` (every +failing subject covered), `partial` (some) or `expired` (the finding fails +again; the lapsed waiver is shown) with the waivers quoted, the `effective` +numbers (conformant, MUST, SHOULD, score, per layer) and the `unused` +waivers that match no finding. Expiry is at most 366 days ahead; a waiver is +immutable but for its revoke, which keeps the row as history; one active +waiver per `(service, clause, symbol)`, a new one after it expires. Schema +errors are not waivable — an invalid pack is never graded. `/api/validate` +and the library routes answer the bare report. + +```bash +# waive (operator): the body is { ruleId, artefactId?, reason, expiresAt } +curl -sS -X POST -b cookies.txt -H 'X-Observogram-CSRF: 1' -H 'Content-Type: application/json' \ + -d '{"ruleId":"L5.MUST.tier1_chaos_for_each_slo","artefactId":"slos.consumer_success_99_95","reason":"chaos day is scheduled for Q1","expiresAt":"2027-03-31T00:00:00Z"}' \ + http://127.0.0.1:8000/api/services/12/waivers +# list (viewer): history included, newest first +curl -sS -b cookies.txt http://127.0.0.1:8000/api/services/12/waivers +# revoke (operator) +curl -sS -X POST -b cookies.txt -H 'X-Observogram-CSRF: 1' -H 'Content-Type: application/json' \ + -d '{"reason":"the chaos experiment landed"}' http://127.0.0.1:8000/api/waivers/7/revoke +``` + +The `author` and `revokedBy` a waiver shows are the audit actor — a login +or the token label, never an email — and every member of the org reads them +(the list is a viewer route), as does anyone the exported report or a +sidecar file is shared with. Without a server, the same waiver object in a +sidecar file drives `packc conformance --waivers ` (next section). + +### Report Placeholders (pack conformance) + +```bash +npm run pack-conformance -- path/to/pack.yaml [more.pack.json ...] [--json] [--strict] [--quiet] [--waivers ] +packc conformance path/to/pack.yaml +packc conformance path/to/pack.yaml --waivers waivers.json --strict +``` + +A valid pack can still be full of placeholders — the values an importer (the +upconvert, the crawler, the live fetcher, Build) had to invent to satisfy the +schema. This tool lists them as rows: the pack path (`$.spec.slis[0]`), the +field, what it needs, and where the value normally comes from — **crawl** (it +exists in the service repository: `npm run crawl` reads it), **telemetry** (a +fact of the running backends: the live fetcher or the backend's API) or +**operator** (a decision only the owning team can make). The detection rule is +the studio's: an artefact whose symbol carries a `crawler.scaffold.*`, +`mcp.scaffold.*` or `library.todo.*` annotation is a placeholder, so the rows +are what Discover parks as Scaffold. Each row's state is `placeholder` (marker +present, value still a stub), `marker-only` (value changed — if real, delete the +marker), `unmarked` (no marker, but the value is an importer's stub literal) or +`dangling` (a marker naming nothing). Exit 0 when every pack is readable, +canonical and valid (rows are informational); 1 for an unreadable, layered +(upconvert it first) or invalid pack, or with `--strict` when any pack still +has rows; 2 for usage. `--json` prints `{ tool, specVersion, strict, packs: +[{ path, valid, errors, rubric, rows, counts, … }], totals, exitCode }`. The +maturity rubric (Diagnose) grades what is declared, placeholders included; the +rows are what still has to become real; the server answers the same report at +`GET /api/packs/:id/placeholders` (and the static bundle in the browser). The +workflow, the marker contract and +the merge-safe `upconvert-legacy` (`-o` onto an existing canonical file merges; +`--merge`, `--overwrite`) are in [`docs/DOWNSTREAM.md`](docs/DOWNSTREAM.md) §11. + +`--waivers ` applies a waiver file — `{ "version": 1, "waivers": [{ +"ruleId", "artefactId"?, "reason", "expiresAt", "author" }] }`, the same +object the server's `GET /api/services/:id/waivers` serves per row, so a +file can be written by hand or saved from the API ([Waive A Conformance +Finding](#waive-a-conformance-finding)) — to every pack: a placeholder row +whose `(rule, symbol)` an active waiver covers (`ruleId` the row's rule, +`placeholder.slos.objective`; `artefactId` the row's symbol, or omitted for +every row of that rule) keeps its state, gains `waived` and is counted in +`waived`, and `--strict` no longer fails on it; a waived rubric clause is met +in the rubric line's `(+n waived)` and `(m% with waivers)` and in `--json`'s +`rubric.waivers.effective`, the engine's own numbers never rewritten. An +expired waiver is shown as lapsed and covers nothing; a revoked one is +history. Without the flag the output is byte-identical to before; an +unreadable file is exit 2 naming the entry. + +### Classify Typed Packs + +Observogram groups a pack's artefacts into families — SLI, SLO, recording +rule, dashboard, alert route … — and the Discover board, the drawer, the +diff and the graphs all read that one classification +(`tools/lib/artefact-classify.mjs`: an explicit `type` first, then the +canonical `defines` symbol the adapter attaches, then the id prefix). A pack +produced by another toolchain carries typed artefacts (`type: PackSLI | +PrometheusRule | …`) with its own id scheme and would otherwise render as a +flat wall of "Other". Point the server at a JSON file that maps those names +and id patterns onto Observogram's families: + +```json +{ + "version": 1, + "types": { "PackSLI": "sli", "PackSLO": "slo", + "PrometheusRule": { "family": "alert_rule", "label": "Prometheus rule" } }, + "ids": [ { "pattern": "^promrule-", "family": "alert_rule", "flags": "i" } ] +} +``` + +```bash +OBSERVOGRAM_TAXONOMY=./taxonomy.json npm run dev +``` + +The server reads the file once at start (`[taxonomy] loaded : N types, +M id rules`), installs it process-wide and serves it to the studio at +`GET /api/taxonomy` (`{ ok, taxonomy, configured }` — never the path). An +unreadable or invalid file **refuses the start** with +`OBSERVOGRAM_TAXONOMY: : `. A value is a family name or +`{ family, label?, role? }`; the family decides the board group +(`FAMILY_HOME` in the classifier) and the label/role replace the row's +plain-words kind. Type names match exactly (case-sensitive); a pattern must +start with `^`, be at most 200 characters, use flags `""` or `"i"` and hold +no quantified group, and is matched against the first 256 characters of the +id. The file is operator-trusted configuration: regexes run server-side +against the ids of uploaded packs. Observogram's own adapted artefacts are +never re-homed by it — their canonical `defines` symbol wins over any id +rule. In identity mode an anonymous studio boots on the default families +until sign-in (`/api/taxonomy` is a viewer route, like `/api/examples`). +Unset, nothing changes: the families are Observogram's own and the board +goldens (`npm run test:golden:board`) are byte-identical. + +**Schema version 2 adds a glossary.** With `"version": 2` the file may carry +a `glossary` — the definitions the studio shows beside a family label or a +spec term, as a mark a person can open (below, *The glossary marks*): + +```json +{ + "version": 2, + "types": { "PackSLI": "sli" }, + "glossary": [ + { "term": "Service level indicator", "family": "sli", "aliases": ["SLI", "SLIs"], + "definition": "A measurement of how the service behaves for its users.", + "link": "https://example.com/handbook/sli" }, + { "term": "Error budget", "definition": "The share of the window an objective allows to fail." } + ] +} +``` + +An entry is `{ term, definition, family?, aliases?, link? }`: `term` and each +alias one line of 1–80 characters, `definition` one line of 1–600, `family` +one of Observogram's families (at most one entry per family), `link` an +`http(s)` URL of at most 2000 characters that carries no credentials (it is +served to every viewer); at most 500 entries, and no term or alias defined +twice (case-insensitive). A glossary never changes a classification — the +families, the board, the diff and the graphs read `types` and `ids` only — and +a version 1 file stays valid: it compiles exactly as before, to an empty +glossary; `glossary` under `version: 1` is refused as an unknown key, so a +file written for an older server keeps working on this one and the other way +round. The boot line counts the terms (`…: 7 types, 1 id rule, 6 glossary +terms`), `GET /api/taxonomy` serves the document with its glossary, and the +static bundle bakes it like any other taxonomy file. +`tools/fixtures/taxonomy/taxonomy.v2.json` is the worked v2 example beside +the v1 `taxonomy.json`. + +**The glossary marks.** Where the studio names something the glossary +explains, a small `?` mark follows the label: on Discover, the kind of each +row (its family's entry, else an entry whose term or alias is the label), the +board's group titles (the first family at home in the group that has an +entry, else the title as a term or alias) and the head facts (`Criticality`, +`Backends` … by label); in the drawer, a *kind* row with the family's entry +and any section head or field label the glossary knows. The mark is a real +button, not a tooltip: its name reads "What is