Skip to content

Latest commit

 

History

History
1971 lines (1761 loc) · 131 KB

DOWNSTREAM.md

File metadata and controls

1971 lines (1761 loc) · 131 KB

Vendoring Observogram's libraries and upgrading the snapshot

A downstream distribution — a private build with its own backend access, auth, branding or artefact taxonomy — runs a vendored snapshot of Observogram's pure libraries plus a thin layer of its own. This document is the workflow for that snapshot: snapshot → verify hashes → run module smoke tests → bump. The contract it rests on is one generated file, VENDOR-MANIFEST.json.

VENDORING.md is the companion document for the studio-side set (the verdict/diff engines, the design system) and the prose contracts upstream keeps stable; the tools/lib rows of its table are a subset of the manifest.

1. What the manifest is

VENDOR-MANIFEST.json, at the repository root, lists every module under tools/lib a downstream may copy verbatim. It is generated by npm run vendor-manifest (tools/gen-vendor-manifest.mjs --write), committed, and guarded by tools/test-vendor-manifest.mjs in npm test: a stale manifest fails the build with the one command that fixes it.

Field Meaning
manifestVersion the shape of this file; 1 only gains keys
package, version package.json's name and version — the upstream release the snapshot belongs to (unreleased work accrues under the last released version)
root tools/lib — every path below is relative to the repository root and lives under it
modules[path].sha256, .bytes the raw bytes of the file as committed (.gitattributes forces LF for *.mjs)
modules[path].exports the exported names, code-point sorted, read statically from the source and cross-checked against the real module namespace by the guard
modules[path].imports the other listed modules it imports (relative specifiers resolved) — copy them together
modules[path].npm the npm packages it needs (@prometheus-io/lezer-promql for promql-lezer.mjs; nothing else) — the static bundle's builder reads it too: a bundle inlines the packages this field names for the modules it carries, and the packages those import (§10)
modules[path].changedSinceRelease true when the module's exports differ from the ones released as version; then releasedExports carries the released list (null for a module new since the release)
data[path] non-code files under the root (tools/lib/site/inventory.schema.json), sha256 and bytes
excluded[path] the modules under tools/lib that are not vendorable, each with the reason
removedSinceRelease modules that left the set since version

It carries no timestamp: regenerating it on an unchanged tree is byte-identical, so the diff between two snapshots is exactly what changed.

2. The inclusion rule

A module is listed when, mechanically:

  1. it imports no node:* module, reads no process (but for exactly the two guarded forms globalThis.process?.cpuUsage?.() and globalThis.process?.platform — PROCESS_READS_ALLOWED: the CPU time and the platform artefact-classify.mjs's id clocks read where the runtime has them; a browser reads undefined, and the wall clock decides), touches no DOM or web storage (document, window, navigator, localStorage, sessionStorage), reads no import.meta and calls no require() — judged on code, never on comments or string text;
  2. every relative import resolves to another listed module (the import graph stays inside the set) and every bare import is a declared package.json dependency;
  3. its export forms are plain (export const|let|var|function|class and export { … } lists; export default and export * make generation fail so support is added deliberately);
  4. it is not in EXCLUDED in tools/gen-vendor-manifest.mjs.

Every .mjs under tools/lib is listed or excluded, never neither — the guard enforces the coverage. The exclusions, with the reasons the manifest publishes:

Module Why it is out
tools/lib/journey.mjs node:fs/path/url/util, an import-time schema read, imports of studio/diagnostic-grade.mjs and studio/verify-deploy.mjs, a dynamic import of tools/fetch-live-pack.mjs — the server-side journey runner, the documented browser-safety exception
tools/lib/brand-env.mjs node:fs/path and process.env — server-side brand/env resolution
tools/lib/grafana-mcp-bridge.mjs a node:http server and network calls — a bridge process
tools/lib/harness.mjs process.stdout / process.exit — the repo's own test harness
tools/lib/retrofeed.mjs pure by the rules, but it imports studio/verify-deploy.mjs, so its import graph leaves tools/lib

studio/metric-readers.mjs and the other zero-import studio modules VENDORING.md names are not under tools/lib and are not in this manifest (see Not covered).

3. Snapshot

  1. Pick the upstream tag or commit and record it next to your copy (a UPSTREAM file, a lockfile comment — anything you will read again).
  2. Copy VENDOR-MANIFEST.json and tools/gen-vendor-manifest.mjs into your tree. The script imports node built-ins only and nothing from tools/lib, so it runs anywhere beside the manifest.
  3. Copy the listed modules you need with the modules their imports name, preserving the tools/lib/… layout under one directory so the relative imports resolve. Copy the bytes verbatim: a tool that converts line endings produces files whose hashes differ (--verify says so).
  4. Install the npm packages the npm fields name, at the versions upstream's package-lock.json pins (a static bundle build refuses any other, §10).

4. Verify hashes

node tools/gen-vendor-manifest.mjs --verify <vendored-root>

<vendored-root> is the directory that holds VENDOR-MANIFEST.json and the tools/lib/… tree. Each listed module and data file prints ok, drift (the bytes differ from the manifest — with a hint when only the line endings do) or missing; the exit code is 1 on anything but ok. It never regenerates anything.

A local patch is drift by definition. Keep patches as a separate overlay applied after the copy, so --verify on the pristine copy stays clean and the overlay is the list of what to re-apply at the next bump.

5. Run module smoke tests

node tools/gen-vendor-manifest.mjs --smoke <vendored-root>

Imports each listed module from your copy and compares its export names with the manifest; a module whose dependency was not copied, or whose npm package is not installed, fails here with the loader's error. For deeper coverage copy the upstream unit test of a module and run it with node --test:

Module Upstream test
adapter.mjs tools/test-adapt.mjs
blast-radius.mjs tools/test-blast-radius.mjs
chain-history.mjs tools/test-chain-history.mjs
compile.mjs, burn-rules.mjs, dashboards/* tools/test-compile.mjs, tools/test-golden-compile.mjs
crawler.mjs tools/test-crawl.mjs, tools/test-golden-crawl.mjs, tools/test-crawl-canonical.mjs
diff.mjs tools/test-diff.mjs
inventory-coverage.mjs, site/* tools/test-inventory-coverage.mjs, tools/test-gen-site.mjs
legacy.mjs tools/test-legacy-pack.mjs, tools/test-upconvert-merge.mjs
library.mjs tools/test-library.mjs
mini-yaml.mjs tools/test-mini-yaml.mjs
neuron-model.mjs tools/test-neuron-model.mjs
pack-conformance.mjs tools/test-pack-conformance.mjs
profiles.mjs tools/test-profiles.mjs
promql.mjs, promql-lezer.mjs, promql-canon.mjs tools/test-promql.mjs, tools/test-promql-canon.mjs
schedule.mjs, schedule-snippets.mjs tools/test-schedule.mjs, tools/test-schedule-snippets.mjs
service-keys.mjs tools/test-service-keys.mjs
stack-evidence.mjs, contracts/stack-self-metrics.mjs tools/test-stack-evidence.mjs, tools/test-stack-self-metrics.mjs
svg-charts.mjs tools/test-svg-charts.mjs
traceability-graph.mjs tools/test-traceability-graph.mjs
validator.mjs tools/test-validator.mjs
contracts/mcp-capabilities.mjs, contracts/response-shapes.mjs tools/test-contract-guard.mjs, tools/test-contract-shapes.mjs
zip.mjs tools/test-zip.mjs

The golden suites read tools/fixtures/ — copy that directory with them.

6. Bump

  1. Diff the old and the new VENDOR-MANIFEST.json. Per module, exports added or removed is an API change; removedSinceRelease and the excluded table list what left the set; a module whose exports are unchanged while its sha256 moved is a behaviour update, not an API break. changedSinceRelease in the new manifest tells you what moved since its release; between two releases, read the release section of CHANGELOG.md for every module the diff names — upstream names each one there.
  2. Re-copy the modules (step 3), re-apply your overlay, run --verify and --smoke (steps 4 and 5), then your own tests.
  3. Record the new upstream tag or commit.

7. What upstream promises

  • The exports of a listed module change only with a ## Unreleased entry in docs/CHANGELOG.md that names the module (path or backticked basename); tools/test-vendor-manifest.mjs fails the build otherwise. The mention check is a text heuristic backed by review of the manifest diff in the pull request — it is the signal, the CHANGELOG entry is the notice.
  • A module leaves the set only with a CHANGELOG entry naming it.
  • Listed modules stay pure by the rule above; the guard asserts it on every run, and asserts the excluded ones still break a rule so the rule cannot go soft.
  • The manifest shape (manifestVersion: 1) only gains keys.
  • Static export extraction equals the real module namespace for every listed module (asserted on every run).

8. Not covered

  • Server routes, studio views and the store: the manifest is the library contract, not an API contract.
  • The zero-import studio modules VENDORING.md lists (diagnostic-grade, artifact-model, constants, verify-deploy, metric-readers): still the prose table, still CI-asserted zero-import; folding them into the manifest (which would also let retrofeed.mjs enter the set) is a follow-up.
  • The npm packages a static bundle carries beyond the npm fields' roots (@lezer/lr, @lezer/highlight and @lezer/common, which @prometheus-io/lezer-promql imports): the manifest names the roots; every package a build embeds is listed by the bundle itself — its licence block and the builder's --json thirdParty (§10).
  • The other downstream seams are configuration the snapshot reads (§9: the transport hook, the taxonomy, the reverse-proxy identity, the brand; §10: the embeddable studio bundle); the Decisions — downstream seams section of UPDATE_JOURNEY.md tracks them.
  • Platform-specific behaviour beyond what §13 states: Windows as §13 says (tested in CI on windows-latest); macOS is untested (the POSIX suites run there, no CI leg).

9. The private plugin layer

A downstream runs the vendored snapshot plus a thin layer of its own. Each seam is configuration the snapshot reads, never a patch to it — so a bump is a bump. The surface so far:

Seam Configuration Where it is documented
Backend access (W2) OBSERVOGRAM_TRANSPORT_HOOK=<path.mjs | file:URL> — a module exporting prepareRequest({ url, headers }) and/or fetchImpl(url, init), applied to every MCP request of the CLI, the recorder, the probes, journeys and the studio server; OBSERVOGRAM_ALLOW_LOCAL_MCP=0 still binds the URL it returns MCP_INTEGRATION.md, "Transport hook"
Reverse-proxy identity (W5) OBSERVOGRAM_TRUST_PROXY_AUTH=1 + OBSERVOGRAM_TRUST_PROXY_AUTH_ACK=only-the-proxy-reaches-this-port, the header names (PROXY_AUTH_USER_HEADER, _EMAIL_HEADER, _NAME_HEADER, _GROUPS_HEADER), PROXY_AUTH_GROUP_ROLES (g=role,*=role, authoritative in PROXY_AUTH_ORG), PROXY_AUTH_JOIN_ROLE, PROXY_AUTH_OWNERS (grant-only), PROXY_AUTH_SHARED_SECRET + _SECRET_HEADER (required beyond loopback), PROXY_AUTH_LOGOUT_URL, PROXY_AUTH_REALM; users are proxy://<realm>#<user> rows of kind oidc; refuses to start without the ACK, beside OIDC, exposed without the secret, or with a join role beside a groups header README, "Behind a reverse proxy (trusted headers)"
Studio bundle (W6) npm run build:studio -- [--pack <file> [--id] [--label] [--description]]… [--pack-url <url> [--id] [--label] [--description]]… [--taxonomy <file.json>] [--brand <file.json>] [--mcp-settings-policy <file.json>] [--mcp-origins <origin>,…] [--out dist/studio/index.html] [--remote-fonts] — one static HTML file with its licences: every studio and tools/lib module in an import map of data: URLs (and the npm packages the PromQL parser needs, as installed from the lockfile), the stylesheets inlined, the packs and the schema as JSON, studio/static-backend.mjs answering the pack routes and Compare, retrofeed and the audit report in the browser and 501 denied: 'no-backend' for the rest; no server configuration at all; no font from the network unless --remote-fonts keeps the shell's Google Fonts links; the taxonomy and the brand baked (W3/W4 kept at the edge: --taxonomy / --brand, or the server's own variables when the flags are absent) §10 below; README, "Serve The Studio Without The Server"
Branding (W4) OBSERVOGRAM_BRAND_FILE=<path.json> — { name, shortName?, wordmark?: { lead, tail }, tagline?, titleSuffix?, description?, logo?: { url | svg }, favicon?, docsUrl?, footer?: { text, links[] }, about?: { changelogUrl }, hero?: { src, alt }, tokens?: { light, dark } }, and/or the scalars OBSERVOGRAM_BRAND_NAME / _SHORT_NAME / _TAGLINE / _LOGO_URL / _DOCS_URL / _FOOTER / _ACCENT / _ACCENT_DARK; one name derives every other string. Read once at start (a bad file refuses the start), rendered into the shell (GET /, GET /index.html, the SPA fallback: title, description, header, footer, #brand-config, #brand-tokens, favicon), the auth pages and, through #brand-config, the studio chrome; node tools/gen-design-tokens.mjs --brand <file> --out <path> for the tokens as JSON. The brand module (tools/lib/brand.mjs) is a listed module: a downstream studio can normalize and render the same object. Baked into the static bundle by --brand (§10) README, "Rebadge The Studio (brand config)"; UI_CONVENTIONS.md §3
Waivers (sidecar) (B3.2) packc conformance --waivers <file> — a JSON file { "version": 1, "waivers": [...] } holding the waiver object GET /api/services/:id/waivers serves per row (ruleId, reason, expiresAt, author, optionally artefactId, createdAt, id), read before any pack by tools/lib/waivers.mjs readWaiverFile (unreadable or invalid → exit 2 naming the entry); the rubric waivers mark the rubric line and --json's rubric.waivers, the placeholder.<family>.<field> waivers the placeholder rows' waived partition (--strict fails on unwaived rows alone); author is the audit actor — a login or the token label, never an email — and visible to anyone the file is shared with. No server configuration; the static bundle grades a pack bare (§14 B3.2-bundle-waivers) §14 waivers; CONFORMANCE.md, "Waivers"; ADAPTER.md, "Waivers — a service record's suppression of a finding"; README, "Waive A Conformance Finding"
Response path annotation (B3.3) metadata.annotations["observogram.remediates.remediation[<i>]"] = "<symbol>[, <symbol>…]" in a pack — alerting.rules[<j>], policy.burn_rate_alerts[<j>], slos.<id> or alert:<slug> — names the alert(s) a remediation answers to when its trigger does not; read by tools/lib/remediation-flow.mjs, a symbol naming nothing is a warning, never a link. No server configuration §14 diagnose-remediate-flow; ADAPTER.md, "Response path"
Artefact taxonomy (W3) OBSERVOGRAM_TAXONOMY=<path.json> — { version: 1 | 2, types: { <TypeName>: <family> | { family, label?, role? } }, ids: [{ pattern, family, flags?, label?, role? }], glossary?: [{ term, definition, family?, aliases?, link? }] } (the glossary needs version: 2; a v1 file stays valid — §14 glossary), read once at start, installed process-wide for the diff and the graphs, served to the studio at GET /api/taxonomy; an unreadable or invalid file refuses the start. The classifier itself (tools/lib/artefact-classify.mjs) is a listed module. Baked into the static bundle by --taxonomy (§10) README, "Classify Typed Packs"; ADAPTER.md, "Id families and the classifier"
MCP server settings policy (B4.3) OBSERVOGRAM_MCP_SETTINGS_POLICY=<path.json> — { version: 1, rules: [{ when: { field | type, pattern, flags? }, warn, require?: { ack } }], generic?: { path, names, auth } }, strict, read once at start (an unreadable or invalid file refuses the start), served at GET /api/mcp-settings. Adds friction only: advisory on the browser-direct path (a reader with devtools skips it), re-checked by the opt-in pass-through (OBSERVOGRAM_MCP_ADMIN_PROXY=1) against the description the server reads; a downstream that needs enforcement enforces it in the MCP server. A rule whose field is absent requires its ack. Acks never persisted. Baked into the static bundle by --mcp-settings-policy (§10) §16; MCP_INTEGRATION.md, "Server settings (admin configuration)"

A minimal plugin layer for the backend seam is one file the deployment points at:

# hooks/gateway.mjs: export function prepareRequest({ url, headers }) { … }
OBSERVOGRAM_TRANSPORT_HOOK=/opt/observogram-plugins/hooks/gateway.mjs npm run dev

The client the hook plugs into, tools/lib/mcp-client.mjs, is itself a listed module of this manifest: a downstream that vendors it gets the same transport parameter (createMcpClient({ mcpUrl, mcpAuth, timeoutMs, transport })) and the same TransportHookError contract in its own tooling.

10. Embedding the studio

A downstream that serves the studio from its own static host does not run the Express server in front of it; it builds the studio from the vendored snapshot as one file and serves that:

# From the snapshot (the whole tree, not only tools/lib — the bundle inlines studio/ too),
# with its package-lock.json, VENDOR-MANIFEST.json and LICENSE, installed from the lockfile:
npm ci
node tools/build-studio-bundle.mjs \
  --pack packs/orders.pack.yaml --id orders --label "Orders" --description "Order intake and fulfilment" \
  --pack-url https://packs.example.internal/payments.pack.yaml --label "Payments" \
  --taxonomy taxonomy.json --brand brand.json \
  --out dist/studio/index.html

Each pack takes its own --id, --label and --description — the catalogue row the bundled studio shows. The file loads no font over the network: the shell's three Google Fonts lines (two preconnects and the stylesheet) are dropped, and the stylesheets' font stacks apply. --remote-fonts keeps them, for readers who are online, and the page then asks fonts.googleapis.com and fonts.gstatic.com for the fonts; --no-remote-fonts, the default, is still accepted, and the two together are a usage error.

What is in the file: studio/index.html with every stylesheet inlined in place, an inline import map whose keys are studio/<file>.mjs and lib/<path>.mjs (for tools/lib) and whose addresses are data: URLs of each module with only its import-specifier strings rewritten, the packs' canonical manifests and the spec schema as a JSON block, and studio/static-backend.mjs installed before studio/app.mjs boots. The shim is the route table of the bundle — each handler is a port of the server/index.mjs handler it names, over the same tools/lib engines, and tools/test-studio-bundle.mjs compares every ported route (status, content type, body; the export ZIP's headers and entry names) against a running server:

Answered in the browser Answered 501 { ok: false, denied: 'no-backend', error: '<Feature> needs the Observogram server; this studio is a static bundle built without one.' }
GET /api/packs, /api/packs/:id (+ ?env=), /canonical (JSON, ?format=yaml), /conformance, /compile-catalog, /compile-artifact, /compile/:target, /export.zip (the Export button downloads it as a Blob); /api/compile/targets, /api/maturity-rubric, /api/version, /healthz, /api/taxonomy (unconfigured, or the document --taxonomy baked — a v2 file's glossary included, so the glossary marks draw as on a server), /api/mcp-settings (proxy: false; the policy --mcp-settings-policy baked or null, and mcpOrigins when --mcp-origins baked a list), /api/examples and /api/references (empty), /api/live-status (present: false); /api/packs/:id/verdicts (the empty document — a bundled pack is never registered, so this is the server's own answer); /api/packs/:id/placeholders (the placeholder report, the zero-store engine in the browser); /auth/me → 404 { ok: false, error: 'identity not configured' } (the open posture); GET /api/diff (Compare: the diff and its traceability graph, computed in the browser over two packs the bundle carries — the PromQL parser and the packages it imports inlined from the lockfile); POST /api/packs/:id/retrofeed (retrofeed, the one non-GET answered: computed in the browser over two packs the bundle carries, written nowhere, with the server's body parsing and refusals — a Content-Encoding, a JSON body in a UTF charset other than UTF-8 and a text body's charset are not ported); GET /api/packs/:id/audit-report (the service audit report, JSON or HTML with the bundle's brand over its own design kit, its goes-blind section computed in the browser; a bundle has no store, so assessments and waivers are available: false, "not recorded by this build"; the generator is <product> static bundle, pack.source bundle or url; the Conformance view's download anchors are answered by the shim's click handler as a Blob download) every other /api or /auth path and every non-GET but retrofeed: Scan a repo (/api/crawl*), Draft from MCP, Refresh from MCP, Testing an MCP connection (/api/mcp/ping), Building a pack from a live MCP server (/api/mcp/jobs), uploads (/api/validate, /api/uploads), Deploy, Journeys, Build (/api/library*), Verdicts (PUT / DELETE …/verdicts/:artefact), Waivers (/api/waivers, /api/services/:id/waivers — a bundled pack has no service record), Services (/api/services, /api/services/:id — the records behind the home's cards and the service page; a bundle has no services table, so the home lists the services its packs name), Organisations (/api/orgs — the active org's name and effective role), Settings (/api/org, /api/org/members, /api/environments/:id, /api/mcp-endpoints, /api/audit — a bundle has no org, so Settings shows this sentence), Passing MCP server settings through the studio server (/api/mcp-settings/describe, /submit — the opt-in pass-through; the bundle's Server settings modal sends from the browser), sign-in (/auth/*)

Swapping packs is a rebuild: --pack inlines a file validated against the schema at build time; --pack-url names a URL the page fetches at its first catalogue read (the host must answer CORS; a URL that fails is a catalogue entry with ok: false, never a failed boot; a URL carrying userinfo or a credential query parameter is refused, because the file is distributed). Upgrading is a snapshot bump, an npm ci and a rebuild — the bundle has no configuration of its own to migrate.

Rebadge batch 2, B1: baking the taxonomy and the brand

The two seams a server reads from its environment (§9, W3 and W4) are baked at build time, through the server's own code on both sides. --taxonomy <file.json> is read, parsed and validated with validateTaxonomy (tools/lib/artefact-classify.mjs) exactly as server/taxonomy.mjs does — a bad file fails the build with --taxonomy: <path>: <reason>, the server's texts — and the document is written into the bundle's config; the shim's GET /api/taxonomy then answers { ok, taxonomy, configured: true } with Cache-Control: no-store, the server's shape, and the studio's boot binds it as it binds a server's, so Discover groups a typed pack as a server started with OBSERVOGRAM_TAXONOMY does. --brand <file.json> goes through tools/lib/brand-env.mjs loadBrand — the server's one loader, so the OBSERVOGRAM_BRAND_* scalars apply on top of the file as on a server — and the shell is brandShellHtml's rendering of studio/index.html (title, description, header, footer, #brand-config, #brand-tokens, favicon) before the stylesheets are inlined, so #brand-tokens lands after design-tokens.css as on the server; the chrome, the notice and the 501 texts read the product from #brand-config, and a branded bundle renders the upstream name nowhere. When a flag is absent the server's variables are honoured (OBSERVOGRAM_TAXONOMY, OBSERVOGRAM_BRAND_FILE, the scalars, the legacy TOMOGRAPH_ spelling): a build machine configured for a server bakes what that server shows; build unbranded there by unsetting or emptying the variable (an empty value counts as unset): env -u OBSERVOGRAM_BRAND_FILE in a POSIX shell, set OBSERVOGRAM_BRAND_FILE= in cmd, $env:OBSERVOGRAM_BRAND_FILE='' in PowerShell. Brand URLs must not be server paths — favicon, logo.url and hero.src are an absolute URL, a data: URL or a path relative to the bundle's own directory (resolved against the page's URL by the static host; the builder copies no asset), and /assets/x fails the build naming the field and the fix. The default brand's Discover hero is the server asset /assets/observogram-hero.png in every unbranded bundle, and an unnamed but configured brand inherits it: the static host serves that asset or the brand names its own hero.src. The summary line and --json always say what was baked (taxonomy: { source, file, types, ids } | null, brand: { source, file, name } | null — the paths, never the contents; no operator path lands in the bundle). Proof: tools/test-studio-bundle.mjs T8 compares a --taxonomy bundle with a server started with the same file (body, header, the board byte-identical to the typed-canonical.mapped golden of tools/test-golden-board.mjs), T8b a --brand bundle's shell fragments with a branded server's, T4b the unconfigured build with the same tree's build with the seams unset (byte for byte), T9 the baked bundle in headless Chromium.

Rebadge batch 4: baking the MCP settings policy and the MCP origin list

--mcp-settings-policy <file.json> bakes the MCP server-settings policy (§9, the file a server reads from OBSERVOGRAM_MCP_SETTINGS_POLICY): read with one leading byte-order mark stripped, parsed and compiled by compileSettingsPolicy (tools/lib/mcp-server-settings.mjs) as server/mcp-settings-policy.mjs does, plus the id clocks the server's start-time load skips — a bad file fails the build with --mcp-settings-policy: <path>: <reason>, the server's texts, and a pattern a server loads can fail it as rules[<i>].when: pattern too slow against a 256-character id — and served by the shim's GET /api/mcp-settings as { ok, proxy: false, policy, configured: true }. --mcp-origins <origin>,… bakes the origins the bundle's Server settings modal may send settings to from a page not served on the MCP server's machine (a bundle on a CDN configuring an https MCP server), read by the server's list rule (parseOriginList, tools/lib/mcp-url-safety.mjs; * any origin) — an entry that is not an http(s) origin fails the build, where a server ignores it — and served beside the policy as mcpOrigins: { listed: true, origins } (origins null for *). When a flag is absent OBSERVOGRAM_MCP_SETTINGS_POLICY and OBSERVOGRAM_MCP_ORIGINS are honoured (the legacy TOMOGRAPH_ spelling too); a per-org list has no meaning in a bundle. Both config keys are appended after taxonomy and only when baked, so an unbaked build keeps its bytes (T4b). The summary line names the rule count and the origins; --json adds mcpSettingsPolicy: { source, file, rules } | null and mcpOrigins: { source, origins } | null — never the policy's contents.

Compare, the PromQL parser and the licences (follow-up compare-in-bundle)

What the bundle answers. Three computations a server makes are answered in the browser by studio/static-backend.mjs, over the same tools/lib engines, loaded at a route's first call (the boot does not evaluate them): GET /api/diff (Compare: the diff and its traceability graph), POST /api/packs/:id/retrofeed (the repository update from live — the one non-GET the shim answers, computed and written nowhere) and GET /api/packs/:id/audit-report (closing B3.5-bundle-audit-report, §14). Pair by name and by id needs no change: it re-keys the received diff in the browser over the two packs, as on a server (closing compare-modes-bundle, §15.5). What is proven, and where it stops:

  • The diff is the server's answer body for body — status, reason phrase, content type, the headers the server sets and the raw text — for the requests the studio sends. tools/test-bundle-compare.mjs and tools/test-bundle-compare-pairs.mjs compare it with a running server in Node, from the source (every ordered pair of ten packs in each scope mode the studio sends; every scopeMode spelling on four pairs; the environments; every refusal) and from a CLI-built file (its import map applied by a resolve hook, the shim imported from its own data: address), in every npm test, CI's included. Its C8 compares the in-page answer in headless Chromium under the browser locales en-US, cs-CZ and et-EE; it runs only where Playwright and its browser are installed, which CI's jobs are not. Nothing is proven in Firefox or Safari.
  • Only with the baked taxonomy bound: a page where the studio could not compile the taxonomy built in answers the three routes 500 naming that, never an answer classified by the default families. The studio binds the taxonomy it is served without re-running the 50 ms timing check, which the builder (or a server) ran on the same document.
  • Retrofeed's body is read as the server's parsers read it, case by case, but for three cases the studio never sends: a Content-Encoding, a JSON body in a UTF charset other than UTF-8, and a text body's charset.
  • The audit report differs from a server's in declared fields only: generator (<product> static bundle), pack.source (bundle, or url for a --pack-url pack), assessments and waivers (available: false, "not recorded by this build": a bundle has no store — its GET …/verdicts still answers the empty document a server gives a never-registered pack, which is what a server would say of the pack, where the report says what this build can record), the clock's generatedAt, and for a pack a server has registered tier.service/tier.environment (null: a bundle has no registry).
  • The diff runs on the page's main thread: the largest example pair (krystaline × production-curated) took 0.6–0.8 s in Chromium where it was measured.
  • In the static posture Deploy explains instead of opening, every deploy control Compare, Diagnose and Remediate draw is drawn unavailable with the reason, and each remedy that would name a server action says how a bundle gets a live pack — addressed to the reader, who did not build the file (studio/static-remedies.mjs; tools/test-static-remedies.mjs holds every such phrase in the studio to a disposition).

What the build needs. The snapshot's whole tree, with package.json, package-lock.json, LICENSE and VENDOR-MANIFEST.json from the same upstream commit, and an npm ci at its root before the build. Coming from an earlier snapshot:

  1. Snapshot the whole tree as before; keep those four files from the same upstream commit as the rest.
  2. Run npm ci at the tree's root (Node 22.16 or newer) before node tools/build-studio-bundle.mjs; on a build machine that needs no dev tools, npm ci --omit=dev. Either installs the server's production dependencies too (74 packages: express, openid-client, …), of which a bundle embeds four; an offline build machine needs all of them in npm's cache or a registry mirror (prime it once with npm ci online, then run npm ci --offline).
  3. Build. The human summary line is unchanged; --json gains a last key, thirdParty — each embedded package's name, version, license, entry (its file inside the package) and bytes; no build-machine path.
  4. If you patch an installed package (patch-package, a hand edit), the bundle embeds your patched file and the licence block does not mark it as modified; marking it where its licence asks is yours to do.
  5. A pipeline that rewrites the HTML after the build (a minifier, a CDN optimiser) must keep the #observogram-licenses element; a step that only strips comments keeps it, since it is a script element. verifyBundleNotices(html), exported by tools/build-studio-bundle.mjs, checks a finished file.
  6. Fonts: a build now drops the shell's Google Fonts lines unless --remote-fonts (it kept them unless --no-remote-fonts). A build that wants them for online readers adds --remote-fonts; one that passed --no-remote-fonts needs no change.
  7. Nothing else: no other flag, config key, Content-Security-Policy or host change (data: in script-src was already needed).

The build refuses a tree it cannot account for, with a sentence naming the package and a fix that whoever runs the build can follow: no package-lock.json, one that does not parse or is older than lockfile version 2; a package the lockfile does not list or that is not installed; an installed version or licence id that differs from the lockfile's; a package with no licence id or no licence file; a second copy that a bundled package would load instead of the hoisted one (the fix is an overrides entry in your package.json); an import a package does not declare, a file inside a package, a node:* import; a licence text that cannot be embedded (one holding </script or <!--); no LICENSE; a missing or unparsable VENDOR-MANIFEST.json; no spec provenance in vendor/observability-pack-spec/VERSIONS.json.

What the build checks. The packages a bundle embeds are the ones VENDOR-MANIFEST.json's npm field names for a bundled tools/lib module (today @prometheus-io/lezer-promql, for tools/lib/promql-lezer.mjs) and the packages those import (@lezer/lr, @lezer/highlight, @lezer/common); nothing else under node_modules is read. Each is installed at the version package-lock.json pins, declares the licence id the lockfile records, ships a licence file, and is the copy its importers load. Each entry file is embedded byte for byte as installed, as a data: module under the package's own name — the build changes nothing in it — with the package's licence files, any NOTICE file at its root and the copyright lines its file carries. Observogram's MIT licence is embedded in every bundle, with or without packages. Upstream's tests pin the licences it reviewed (each package's SPDX id and the sha256 of its licence and NOTICE files: THIRD_PARTY in tools/test-bundle-compare.mjs), so a licence change fails upstream's CI until someone reviews it; your own build embeds whatever your lockfile installs.

What it does not check. Whether those licences suit your distribution. The packages' code. That the installed files are the ones the registry publishes: the lockfile's integrity is the hash of a package's tarball, and nothing npm leaves under node_modules records a file's, so a post-install patch is embedded as installed, unmarked. NOTICE files outside the published package: the build does not consult a package's source repository (@prometheus-io/lezer-promql publishes no NOTICE file; the block prints each package's source repository, so a reader can look there). The licences of the packs, brand assets or fonts you build in. The vendored spec, whose schema every bundle embeds, comes from a repository that publishes no licence file; the block states its provenance and claims no licence for it.

The licence block. One <script type="text/plain" id="observogram-licenses"> element at the end of <head> — never executed or rendered, read by textContent — and one comment right after the doctype that points to it, for a person opening the file. Its text: a preamble naming the build and the version, which says each package is embedded as npm ci installed it from package-lock.json and is not compared with the package the registry publishes; ==== Observogram <version> — MIT ==== and the LICENSE text; for each package, in code-point order of its name, ==== <name> <version> — <SPDX id> ====, a File: line (the entry file and its import-map key), a Source: line (the repository its package.json names, when that is an https: URL), a Copyright lines in the file: line (when the file carries any) and each licence file, then each NOTICE file, under ---- <file name> ----; then ==== The ObservabilityPack spec ==== with the upstream commit vendor/observability-pack-spec/VERSIONS.json records. No path of the build machine enters the file, and two builds of one tree with one builtAt are byte-identical. verifyBundleNotices(html) → { ok, packages, problems } checks that the file has one such element, in <head>; an Observogram MIT section with the licence's grant and its disclaimer; one section per package the import map carries — with its File: line and a licence text — and none for a package it does not carry; and the pointer comment's wording. It does not compare the texts with an installed tree.

Size. An empty bundle is 5,454,277 bytes (115 repository modules and the four packages; --remote-fonts adds 372), up from 4,901,598 bytes (109 modules) before this follow-up, both without the font links (+11.3 %): the parser and its packages, the traceability graph and the blast radius, the audit report, retrofeed and the static posture's texts. tools/test-studio-bundle.mjs holds a two-pack build under 6,000,000 bytes, so a dependency that enters the graph unexpectedly is caught where it lands.

On a server too: one collation. Every tools/lib sort passes 'en' to localeCompare — the CLDR root order, which English does not tailor — so the diff, the graph, canonical PromQL and the identity keys and behaviour hashes built from it order text the same under every process and browser locale (without it, a bundle read in a Czech browser ordered its diff differently from the server that built its packs). A server whose process runs under a locale that tailors Latin letters (Czech, Danish, Estonian, Lithuanian, Hungarian, Slovak and the like) orders differently from before; C, C.UTF-8 and en_* do not move. On such a server, an artefact whose identity key holds canonical PromQL, or a sorted list that now sorts differently, gets a new key, so a verdict recorded on it before the upgrade does not carry at the pack's next re-registration. Check the collation where the server runs (node -p "new Intl.Collator().resolvedOptions().locale" in its environment): en, en-US or und — nothing moves; otherwise, after each pack's first re-registration on the new version, read its verdict.carry audit row (dropped, droppedCount) and record those verdicts again. tools/test-vendor-manifest.mjs refuses a localeCompare( or Intl.Collator( under tools/lib that does not pass 'en'.

The image. The Docker image carries LICENSE beside package.json; its node_modules carries each installed package's own licence file.

Follow-ups, by name.

  • bundle-diff-worker — the diff off the page's main thread (a Worker needs its own module resolution: import maps do not apply in workers).
  • bundle-browser-matrix — parity proven in Firefox and Safari.
  • ci-browser-suites — Playwright in CI, so C8 and T7 run there.
  • spec-licence — once the spec's repository publishes a licence, tools/sync-spec.mjs vendors it and the block embeds it.
  • bundle-licences-view — the licences readable from the studio's About entry.
  • bundle-discover-controls — Discover's Scan a repo and Generate live from MCP drawn unavailable in the static posture (they answer the 501 sentence on use).
  • server-third-party-notices — one notices file for the server's own npm closure in the image.
  • --split — a directory form without data: URLs, for a host whose Content-Security-Policy forbids them in script-src.

11. From validates to conformant

A pack that passes npm run validate-pack is valid: every schema-required field holds a value of the right shape. It is conformant when every one of those values is real — none is a placeholder an importer had to invent. The upconvert of a large layered pack emits hundreds of placeholders; this section is the paved road from the first state to the second.

11.1 The marker contract

A placeholder is an artefact whose adapter symbol carries a scaffold marker: an annotation key crawler.scaffold.<symbol> (the upconvert and the crawler), mcp.scaffold.<symbol> (the live fetcher) or library.todo.<symbol> (Build) with a non-empty string value — exactly the three prefixes tools/lib/adapter.mjs reads in sourceOf, so what the conformance tool reports is what the studio parks as Scaffold. The key is the contract; the value is advisory. A library-style value (<field>: <what> · <field>: <what>, with placeholder '<literal>' naming the stub) names its own fields; any other value (the crawler's, the fetcher's, the upconvert's one-line reason) falls back to the engine's per-family field table. There is no second index: the keys are the list.

Two kinds of symbol share the grammar. An artefact symbol is exactly an id the adapter passes to sourceOf — slis.<id>, slos.<id>, otel, telemetry.backends.<id>, storage.<key>, pipelines.receivers[i], pipelines.processors[i], pipelines.exporters.<signal>, queries.recording_rules[i], dashboards.<id>, dashboards.<id>.panels.<panel>, policy.burn_rate_alerts[i], alerting.routes[i], alerting.rules[i], remediation[i], baselines, validation.synthetic_checks.<id>, … — and parks that artefact. Any other dotted or indexed path under an artefact symbol is a field symbol (otel.semconv, telemetry.backends.<id>.endpoints, alerting.routes[0].channels[1], metadata.owners): it parks nothing in the studio or in Compare and exists for the conformance report. Three doors write otel at the artefact level or not at all: the upconvert (crawler.scaffold.otel, tools/lib/legacy.mjs) and the fetcher (mcp.scaffold.otel) park OTEL-01; the crawler marks the five otel fields so a repo-vs-live Compare keeps otel in its not-observed bucket. The engine reads both shapes.

tools/lib/pack-conformance.mjs (a listed module, zero-import, browser-safe) turns the markers into rows:

{ path, symbol, field, needs, source, hint, state, marker, writer, rule }

path is the JSONPath of the artefact in the validator's style ($.spec.slis[0]); field the spec field that needs a real value; needs what it needs in one line; source where that value normally comes from:

  • crawl — the value exists in the service repository (rule files, dashboards, collector and Alertmanager config, runbooks): npm run crawl reads it, or copy it from the file;
  • telemetry — a fact of the running backends (products, versions, semconv, endpoints, incident history): the live fetcher (npm run fetch-live) or the backend's own API;
  • operator — a decision only the owning team can make (objectives, windows, severities, guardrails, cadence, owners, criticality).

state is one of placeholder (marker present, the value still matches an upstream stub literal — or the field has no stub test and the marker decides), marker-only (marker present, the value no longer matches a stub: if it is real, delete the marker), unmarked (no marker, but the value is an importer's stub literal — 0.1.0-legacy, binding: legacy, imports[].ref: legacy/…, expr: vector(1), a +0-000- phone stub, REPLACE WITH REAL QUERY in an SLI description; the crawler's 0.1.0-crawled and ['team-platform'] only on a crawler-written pack) and dangling (a marker whose symbol names nothing: delete it or fix the symbol). rule is the stable placeholder.<family>.<field> string a waiver will scope to (with the symbol); writer says which door wrote the marker. The state heuristics are advisory — a real objective: 0.99 under a standing marker still reads placeholder — the marker key is the only contract, and the report says so.

11.2 The workflow

npm run upconvert-legacy -- old.json -o svc.pack.json      # once: the layered archive → canonical
npm run pack-conformance -- svc.pack.json                   # the rows: path · field · source · what it needs
npm run pack-conformance -- svc.pack.json --json > todo.json
# fill the rows by source: crawl → put the value in the repository and re-crawl, or copy it in;
#                          telemetry → read it off the backend; operator → decide it;
# then delete the artefact's marker (the tail line of each group names it) and re-run
npm run pack-conformance -- svc.pack.json --strict          # CI: exit 1 while any row remains

packc conformance <file...> is the same tool. Exit codes: 0 every pack read, canonical and schema-valid (rows are informational); 1 a pack is unreadable, a previous-format (layered) pack — never auto-converted, so every row points into a file the operator can edit — or schema-invalid, or --strict and any pack has rows (any state: a marker-only artefact is still parked Scaffold, a dangling marker is a broken contract); 2 usage. The maturity rubric (GET /api/packs/:id/conformance, the Diagnose view) is bridged, not hidden: one rubric @ tier line per pack — the rubric grades what is declared, placeholders included; the rows are what still has to become real.

Re-running the upconvert is safe. upconvert-legacy is idempotent in two ways. A canonical input (apiVersion/kind) is validated and passed through unchanged. A legacy input whose -o target already holds a canonical pack — or whose --merge <file> names one — merges under one rule: the existing pack wins for every artefact it has; the upconvert only adds artefacts whose legacy item the existing pack has never seen (by the legacy.artefact.<LAYER>.<ID> record — a deleted artefact stays deleted, a schema-required stub is skipped). Added items land at the end of their list with their marker and type annotation re-indexed to the final position; existing annotations keep their order and values (a marker you cleared stays cleared); the whole legacy.* block is refreshed to the current source. No real value can regress to a scaffold by construction (tools/test-upconvert-merge.mjs proves it over every example). --overwrite restores the plain write.

11.3 Packs born canonical

The recommended end state is not to upconvert at all: crawl the service repository with the upstream crawler. See §12 for what a fresh crawl promises.

12. Packs born canonical: the recommended end state

Three doors, one pipeline. Crawl (tools/lib/crawler.mjs, npm run crawl), upconvert (tools/upconvert-legacy.mjs, for packs from the archived layered format) and Build (tools/lib/library.mjs) all emit the canonical manifest; validation (npm run validate-pack), conformance (tools/pack-conformance.mjs, §11), compile, deploy and Compare read only that. The recommendation: crawl the service repository with the upstream crawler — a fresh crawl validates on exit 0 and needs no upconversion; the upconvert is for the archive, and a re-crawl supersedes an upconverted pack.

What a fresh crawl promises.

  1. It validates against the vendored spec or exits 3 — and exit 3 is a bug to report, never an input error.
  2. Flags that cannot yield a valid pack are refused before the crawl (exit 2, the rule in the message, nothing on stdout): --name, --env and each --owners entry must be spec Slugs, --criticality and --binding spec values. The programmatic doors (POST /api/crawl, the studio's folder scan, a journey definition) get the same rule as a default-plus-warning: an unknown criticality yields the inferred tier and crawler.criticalityIgnored, an unknown binding the default and crawler.bindingIgnored.
  3. Names are normalized to the spec Slug and the original kept: the folder name in crawler.nameNormalizedFrom, the environment in crawler.environmentNormalizedFrom; owners only in the response's summary.normalized.owners (an owner string may be an address and never reaches the stored pack). A name that does not start with a letter is prefixed (1password → svc-1password), never truncated at the front.
  4. What the spec cannot hold is omitted and recorded as evidence, never invented and never silently dropped: recording rules not named <service>:<metric>:<op> in crawler.omitted.recording_rules (name + <file>#<group>/<name>; their expressions still feed the metric inventory), a dashboard schemaVersion below 30 in params.schema_version, a rule-group interval that is not a Duration in the response's summary.omitted.ruleIntervals.
  5. Every value it had to invent is marked (crawler.scaffold.<symbol>, the table below).

The scaffold table — one row per symbol: why the crawler cannot know it, where the real value comes from (§11.1's three sources), how to make it real.

Symbol Why invented Source How to make it real
metadata.owners no repository file names the owning team operator --owners
otel.semconv, otel.resource_attributes, otel.sdk.sampling, otel.sdk.propagators the SDK configuration is not in observability files operator / telemetry state them in the pack (or OTEL_* in the deployment, which a later crawl may read)
otel.sdk.languages invented only when the repository has no source files crawl the source files name the SDK languages (a heuristic over extensions: a Go service with a scripts/*.js helper reports both)
slis.<id>, slos.<id> (stub or alert-derived) no recording rule or SLO reference found operator add <service>:<metric>:<op> recording rules, which the crawler reads back as SLIs; or write the SLI
pipelines.receivers[0], pipelines.processors[0], pipelines.exporters.<signal> no collector config or backend image crawl a collector config, or a backend image in compose/Helm
alerting.routes[0] (stub) no Alertmanager config crawl an Alertmanager config
alerting.routes[i].channels[j] a receiver kind the spec Channel cannot carry, or a config that states no address operator the real address (a webhook URL, a Teams channel URL)
dashboards.<svc>-overview (stub) no dashboard JSON crawl a dashboard JSON in the repository
telemetry.backends.<id>.endpoints the compose service or workload states no port (http://<product>:80 assumed) telemetry / operator the port
baselines MTTD/MTTR come from incident history, not files telemetry measure them
validation.synthetic_checks.<svc>-health-canary no probe definition in the repository operator the real health URL, interval and severity

The symbol grammar is §11.1's, shared by crawler.scaffold.*, mcp.scaffold.* and library.todo.*: artefact symbols are the adapter's sourceOf ids and park the artefact as Scaffold (the stub and alert-derived SLI/SLO pairs — Compare files them in its scaffold bucket, the reading the live side already applies to its own stubs); field symbols add .<field> or an index and park nothing — they exist for the conformance report. A mark's value is a one-line reason. The keys are the index: there is no second JSON list.

Known unmarked defaults (the conformance tool's value heuristics carry them until the named follow-up lands): SLO objective 0.99 / window 30d / error_budget_policy ref:platform/default-budget; SLI total: '1'; SLI threshold: 1 with unit: 'ratio'; burn windows 5m/1h/14/SEV1 + 30m/6h/6/SEV2. Why unmarked: the live fetcher derives the same values through tools/lib/sli-inference.mjs, and Compare pairs them; marking one side would park them. The crawler's version: 0.1.0-crawled is reported unmarked on a crawler-written pack: a fresh crawl validates but is not conformant until the operator names the version.

The workflow.

npm run crawl -- <repo> --name <slug> --env <slug> > pack.yaml
npm run validate-pack -- pack.yaml
npm run pack-conformance -- pack.yaml      # fill the rows: a value a later crawl can read (a rule, a port)
                                           # belongs in the repository, the rest in the pack; delete the mark
npm run pack-conformance -- pack.yaml --strict   # CI, once the pack is conformant

Named follow-ups (one line each; downstreams plan against these names):

  • Inference defaults marked on both sides — SLO objective/window/budget ref, SLI total/threshold and the default burn windows need a joint change in sli-inference.mjs applied by the crawler and the fetcher plus a diff test; marking one side alone would move every crawled SLO out of Compare's inBoth.
  • Artefact-level otel scaffold on the repo side — a product decision: it would move OTEL-01 from Compare's not-observed bucket to its scaffold bucket.
  • Omitting the fabricated synthetic check — the schema allows an empty validation, but the rubric's L5 MUST and the studio pin the parked stub; a product decision on which reading is the honest one.
  • HTTP 400 for invalid crawl options on POST /api/crawl — today the library's default-plus-warning keeps the response a valid pack.
  • -burn-?rate suffix handling in burnCandidateFromAlertRule — a hyphenated alert name yields shop-availability_99 while the recording-rule derivation yields shop_availability_99; unifying the separator renames L1 ids on both producers.
  • javaMetricPrefix's solace_ product rule — kept as a product rule (the path or the source literally says solace); a generic prefix rule read from configuration is the follow-up.
  • legacy.liveness.mcpUrl through stripMcpUrl — the upconvert copies a legacy liveness URL verbatim into the annotations; a URL carrying userinfo or a token query parameter would be stored (none of the shipped examples does). A default-output change for such packs, with its own CHANGELOG line.
  • Waivers (GAP batch 2, B3.2) — delivered (§14 waivers): the engine honours opts.waivers and answers the waived partition; packc conformance --waivers <file> drives it.
  • Service audit report (GAP batch 2, B3.5) — delivered (§14 service-audit-report): the report's placeholders section is packConformance(canonical)'s rows, and GET /api/packs/:id/placeholders answers the same rows (the static bundle too).
  • Imports as Scaffold — the adapter hardcodes source: 'Declared' for metadata.imports, so the upconvert's legacy/<slug> pseudo-refs are reported unmarked and never parked; a board change.

13. 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 and the latest 22, on every PR into develop or main and every push to them. The Windows leg fails unless the run prints README's count of SKIP win32: lines and runs what the Linux floor runs: the same tests, the same harness assertions per file (fewer only under a counted win32 skip) and the same other skips (tools/tap-summary.mjs, whose block ends the job's log). The browser suites (Playwright) run on no CI leg. 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 as # SKIP win32: <reason> by node --test or - SKIP win32: <reason> by a harness suite — never a silent pass. The skipped facts are the ones 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. There are 15 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 a Windows run prints 19 SKIP win32: lines, plus the PID 1 namespace test's # SKIP unshare --pid is unavailable here and the browser suites' Playwright skips when OBSERVOGRAM_PLAYWRIGHT is unset. 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 Linux floor ran; npm test takes 138–219 s there. 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); tools/test-platform.mjs keeps the count and the reasons honest and proves the module-relative resolvers (fileURLToPath, never URL.pathname, which is /C:/… on Windows and percent-encoded everywhere) on every platform. The checkout is LF everywhere (.gitattributes: * text=auto eol=lf), so the goldens and VENDOR-MANIFEST.json's sha256 verify under any core.autocrlf.

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.

The CI leg is the acceptance for windows-latest. Your own Windows box can still differ in ways CI cannot show: an unelevated shell, a profile path with a space, an antivirus that holds a just-written file. To report from there, run this from cmd or PowerShell at the checkout's root:

cmd /c "set NODE_OPTIONS=--test-reporter=tap&& npm test > test.tap 2>&1"
node tools/tap-summary.mjs test.tap --win32-skips readme

From Git Bash:

NODE_OPTIONS=--test-reporter=tap npm test > test.tap 2>&1
node tools/tap-summary.mjs test.tap --win32-skips readme

NODE_OPTIONS names the reporter because Node 23 and later print the spec reporter even into a file; it replaces any NODE_OPTIONS of your own for that run. cmd /c keeps the bytes; Windows PowerShell's own > re-encodes a program's output through the console code page. Send the block the summary prints: a count other than 19, or any failure, is the report. A test that fails only on Windows is first made OS-neutral upstream; only a fact Windows cannot express becomes a reasoned skip site, with the README counts the guard and the Windows leg force.

14. Reliability-workflow features (GAP batch 2)

The five generic reliability-workflow features of rebadge batch 2 (B3), one ### per feature name; downstreams plan retirement waves against these names. Each is a full build behind a documented seam, inert without data and free of downstream vocabulary; the follow-ups are named, with their blocker, so a wave can be scheduled against them.

feature status seam what a downstream retires follow-ups
waivers shipped — full build GET/POST /api/services/:id/waivers, POST /api/waivers/:id/revoke, the waivers block of GET /api/packs/:id/conformance (docs/ADAPTER.md "Waivers"), the sidecar file of packc conformance --waivers, tools/lib/waivers.mjs (listed), the waiver.create / waiver.revoke audit rows its exception / suppression list for conformance findings and its expiry bookkeeping B3.2-studio-waive, B3.2-bundle-waivers, B3.2-env-scope, B3.2-supersedes
diagnose-remediate-flow shipped — full build tools/lib/remediation-flow.mjs (listed): buildRemediationFlowModel, the linking rule and the observogram.remediates.remediation[<i>] annotation (docs/ADAPTER.md "Response path"); the panel studio/remediation-flow-view.mjs on Diagnose (#diag-flow) and Remediate (#rm-flow), the .rflow-* zone its alert → runbook / automation "what next" page and its trigger-to-alert matching remediation-trigger-ref, remediation-flow-graph-unify, remediation-flow-live-state, alert-rule-deploy, catalogue-triggers
glossary shipped — full build the taxonomy file's schema version 2 glossary (OBSERVOGRAM_TAXONOMY / --taxonomy; README "Classify Typed Packs"), tools/lib/artefact-classify.mjs glossaryFor / glossaryByText / glossaryEntries (listed), the mark studio/glossary.mjs (.ux-gloss-*) on Discover rows, board titles, head facts and the drawer its hover/inline definitions for artefact families and domain terms, and the file that held them glossary-light-views, glossary-termhtml-override, glossary-seed-from-spec
verdicts shipped — full build GET/PUT/DELETE /api/packs/:id/verdicts[/:artefact] (docs/ADAPTER.md "Verdicts"), the verdicts.json entry of /export.zip, the verdict.set / verdict.clear / verdict.carry audit rows, the verdict status property (studio/ux-kit.mjs) and studio/verdict-html.mjs its per-artefact review / trust record and its badge, filter and record form verdicts-service-scope, verdicts-bundle-bake, verdicts-cli, verdicts-on-adapter-upgrade
service-audit-report shipped — full build GET /api/packs/:id/audit-report?format=json|html and GET /api/packs/:id/placeholders (docs/ADAPTER.md "The service audit report"), packc audit-report (--verdicts, --waivers, --brand, --taxonomy, --no-timestamp), tools/lib/audit-report.mjs (listed: buildAuditReport, renderAuditReportHtml), the Conformance view's two download anchors; in the static bundle too (follow-up compare-in-bundle, §10) its per-service audit page and export (conformance, exceptions, review state, coverage, single points of failure, runbook linkage) B3.5-export-zip, B3.5-dark-print

waivers

A time-boxed, reasoned suppression of one conformance finding: a rubric clause and, for the four per-item clauses, optionally one canonical symbol of it (slos.<id> / slis.<id> — the adapter's defines vocabulary, never a JSONPath). Persisted on the service record (schema v2, waivers, cascading with the service; a revoke keeps the row as history), applied by GET /api/packs/:id/conformance as a waivers block beside the engine's untouched numbers (effective carries the numbers with the waived clauses met), read by the CLI from a sidecar file (packc conformance --waivers <file>: the rubric line and rubric.waivers, and the placeholder rows' waived partition — the seam §11 reserved). Everything a downstream integrates against is in docs/ADAPTER.md ("Waivers — a service record's suppression of a finding": the waiver object, the overlay block, the POST body, every refusal text), docs/CONFORMANCE.md "Waivers" and README "Waive A Conformance Finding".

Follow-ups, by name:

  • B3.2-studio-waive — the studio shows waivers on the Conformance view and does not record one; the record form is the API's (curl in the README) until a downstream asks for it in the drawer.
  • B3.2-bundle-waivers — the static bundle grades a pack bare (no service record); baking a sidecar file into a --pack needs a build flag and a shim read, like the taxonomy and the brand.
  • B3.2-env-scope — a waiver applies to every environment of its service; ?env= grades per overlay, so a per-environment scope is the next shape (serviceTierFor already returns the environment as the hook).
  • B3.2-supersedes — a renewal is a new row after an expiry; a supersedes link from the renewal to the lapsed waiver would keep the chain readable in the audit.

diagnose-remediate-flow

The response path from a firing alert to the remediation the pack declares for it, computed from pack data alone (the existing spec.remediation family; no external call, no store, no route, no env). The spec names a remediation's trigger as an alert id (trigger: alert:<slug>) and nothing else binds the two, so tools/lib/remediation-flow.mjs is the one linking rule — the annotation metadata.annotations["observogram.remediates.remediation[<i>]"] (alerting.rules[<j>], policy.burn_rate_alerts[<j>], slos.<id>, alert:<slug>, comma-separated), then the rule name, then a compiled burn-rule name (<slo>_burn_<factor>x_<short>_<long>), then the SLO; the first tier with a hit wins and every hit of it links; no hit is unresolved with name-based suggestions that never link. States come from the comparison (live, drifted, missing, unverified, placeholder, unhealthy from the live side's mcp.discovered.alert_rules_unhealthy; declared without one) and the steps are what a responder walks (deploy the missing burn alert through the deploy modal, reconcile, fix, route, register the automation, open the runbook, annotate an unresolved trigger). The studio draws it on Diagnose (a .diag-block with its sticky-index entry) and on Remediate (after the plan, where the deploy button lives); the static bundle draws it too (the engine rides the import map). Everything a downstream integrates against is in docs/ADAPTER.md ("Response path": the annotation grammar, the model shape, the states and steps). Honest status: not one trigger of the catalogue's 17 remediations resolves today — the panel lists them all as unresolved with their suggestions (pinned in tools/test-remediation-flow.mjs).

Follow-ups, by name:

  • remediation-trigger-ref — the spec could let a remediation name its alert as a reference (trigger: ref:alerting.rules[0]) instead of a free slug; an upstream proposal (the vendored spec is pinned by sync-spec), after which the annotation becomes the fallback.
  • remediation-flow-graph-unify — the traceability graph infers its own remediates edges by text; one rule should serve both, and the graph's verdicts should feed the path's states.
  • remediation-flow-live-state — the path reads the comparison; a live probe of the automation backend (did the workflow run?) and of the route (did the channel receive?) is the next evidence rung.
  • alert-rule-deploy — a declared alert rule (alerting.rules[j]) is not a compiled artefact, so a missing one has no deploy action; compiling the declared rules as a deploy surface would give it one.
  • catalogue-triggers — the reference packs' triggers resolve to nothing; renaming them to their alerts (or annotating them) is a reviewed golden change (the board goldens and tools/test-remediation-flow.mjs pin the catalogue), to land with a maintainer's eye on each pack.

verdicts

A reviewer's trusted | suspect | failed record, with a reason, the actor and the time, on one artefact of one registered pack; unreviewed is the absence of a record. Persisted in the store (schema v2, verdicts, cascading with the pack), surfaced on Discover as a board badge, a row chip and a Refine facet, recorded from the drawer, exported as verdicts.json. Never a scorer input: the conformance score and the diagnostic grade do not read it. The artefact address is the adapter's positional id (SLI-01) within a content-hash pack id; a re-upload under the same label carries each record by behavioural identity (identityKeyOf) and drops the rest, counted in verdict.carry. Everything a downstream integrates against is in docs/ADAPTER.md ("Verdicts — a reviewer's record per artefact": the VerdictView, the document, the PUT body, every refusal text) and README "Record Verdicts".

Follow-ups, by name:

  • verdicts-service-scope — a verdict is per pack; a service-level record that survives a pack's eviction (the service row outlives its packs) is the next shape, keyed by the canonical symbol as the waivers are.
  • verdicts-bundle-bake — the static bundle answers the empty document; baking a verdicts.json into a --pack (a read-only badge in a bundle) needs a build flag and a shim read, like the taxonomy and the brand.
  • verdicts-cli — no packc verdicts yet: the API is the one door (curl in README "Record Verdicts"); a CLI over the same admin module is a small follow-up once a downstream asks for a batch path.
  • verdicts-on-adapter-upgrade — a positional id is frozen within a pack id, but an adapter change that renumbers artefacts of an already-registered pack would orphan its rows (served flagged orphaned: true, counted in summary.orphaned); a migration-time re-key by identity key is the fix when such an upgrade ships.

glossary

Definitions for the taxonomy's families and for spec terms, sourced from a glossary section of the taxonomy file — the W3 seam at schema version 2 (TAXONOMY_VERSION_LATEST; TAXONOMY_VERSION stays 1 so a file written for an older server keeps validating, and a v1 file stays valid here). An entry is { term, definition, family?, aliases?, link? }; the classifier refuses a bad one with an exact text and compiles a good one to { entries, byFamily, byText } behind glossaryFor, glossaryByText, glossaryEntries (listed, tools/lib/artefact-classify.mjs). A glossary changes no classification. The studio draws an entry as an accessible mark (studio/glossary.mjs: a named button, the definition opened in place, hover and focus previews, Escape, a "Learn more" anchor for link) beside the Discover row's kind, the board's group titles and head facts, and the drawer's kind row and labels; the static bundle draws the same from a baked v2 file (B1's bake carries the document, tools/test-studio-bundle.mjs T8c). Byte-identical without one: the 24 board goldens, every row (README "Classify Typed Packs", The glossary marks).

Follow-ups, by name:

  • glossary-light-views — the Tiles, List and Cards views draw no mark (the row is one button there, and a button cannot hold a button); a side-mark outside the button is the next shape.
  • glossary-termhtml-override — termHtml() (the studio's own formal terms, studio/ux-kit.mjs GLOSSARY) and the operator's glossary are two voices; letting a v2 entry override a termHtml key's definition by name is a small follow-up once a downstream asks for it.
  • glossary-seed-from-spec — the vendored spec names every family and term; a generator that seeds a v2 glossary from the spec's descriptions (tools/sync-spec.mjs pins the spec, so the seed is a tool, not an edit) would give every downstream a starting file.

service-audit-report

One exportable report per pack, HTML and JSON, in seven sections that each quote an engine this repository already ships: the maturity rubric's grade (the engine's numbers headline; a waivers overlay's effective sits beside them), the placeholders (packConformance rows beside the library-todo and Scaffold counts), a reviewer's verdicts, the service's waivers, the coverage by family (required = named by a rubric clause that applies at the graded tier; required with nothing declared is missing, else absent), the goes-blind risks (tools/lib/blast-radius.mjs over the traceability graph: what WOULD go blind, never that something is blind) and the declared response path (B3.3's model, never compared). Nothing in the report grades, scores or classifies on its own, and a verdict never feeds the conformance numbers. Two artefact addresses appear and each engine's own is printed — a verdict by the adapter's positional id (SLI-01, card L1/SLI-01), a waiver or a placeholder row by the canonical symbol (slos.<id>) — docs/ADAPTER.md "Artefact addresses".

The seams: GET /api/packs/:id/audit-report?format=json|html&top=&download=1&env= (viewer; its conformance section is the one conformanceReportFor() body the /conformance route sends, its verdict and waiver rows the store's), GET /api/packs/:id/placeholders (the rows packc conformance prints; answered by the static bundle too), packc audit-report <pack> [--env] [--format] [--out] [--brand] [--taxonomy] [--top] [--generated-at | --no-timestamp] [--verdicts] [--waivers] [--schema] (the CLI has no store: --verdicts is a saved GET /verdicts document, --waivers the sidecar file; without them the sections read "not recorded by this build"; --brand is the only brand it reads, so a report is reproducible from its arguments), the model and the renderer in tools/lib/audit-report.mjs (listed; buildAuditReport(input), renderAuditReportHtml(report, { brand, styles }) over studio/design-tokens.css + design-kit.css and the .ar-* zone — the first standalone document over the design kit), the Conformance view's two download anchors. Goldens tools/fixtures/golden/audit-report/* pin the document and the HTML for four inputs (unstamped). A downstream with its own sections writes a sibling renderer over the same document: the JSON shape is the contract (reportVersion: 1).

Follow-ups, by name:

  • B3.5-bundle-audit-report — closed by follow-up compare-in-bundle (§10): the bundle inlines the PromQL parser, and the shim builds the report in the browser with goesBlind.available: true, its verdicts and waivers sections "not recorded by this build".
  • B3.5-export-zip — GET /api/packs/:id/export.zip could carry audit-report.json / .html beside verdicts.json; held back because the ZIP's entry list is pinned by the bundle parity suite and the report is stamped (the ZIP is reproducible today).
  • B3.5-dark-print — the HTML is one light theme; a prefers-color-scheme dark variant and a print stylesheet beyond @media print basics wait for a downstream that prints or embeds it.

15. Live-fetch UX (rebadge batch 3)

Four changes to how a live pack is fetched and compared, each its own CHANGELOG entry (## Unreleased, "Rebadge batch 3, C0 … C3"): a caller-supplied MCP URL is a privilege (15.1), a live pack is built as a job whose progress is a polled gate log and can be a true snapshot (15.2), a connection is tested before anything is fetched (15.3), and Compare can pair by name or id as well as by behaviour (15.4). The engines are vendorable: tools/lib/live-fetch.mjs and tools/lib/identity-modes.mjs are new listed modules, and diff.mjs, mcp-capabilities.mjs, service-keys.mjs, crawler.mjs, pack-conformance.mjs, journey.mjs and mcp-client.mjs changed (docs/VENDORING.md has the rows).

15.1 A caller-supplied MCP URL is a privilege

The rule. Supplying the URL a server-side request goes to is the privilege, not which endpoint uses it. A typed mcpUrl in POST /api/mcp/ping, POST /api/mcp/jobs, POST /api/draft-from-mcp, POST /api/refresh-live, the deploy routes (POST /api/packs/:id/deploy/:target, …/deploy-bulk, POST /api/deploys/:deployId/rollback) and a journey's raw Pack B URL needs the admin role in the org (an owner included) — one constant, TYPED_MCP_URL_ROLE in server/mcp-target-policy.mjs, against which sessions and the bearer token are judged by rank. The open postures' anonymous caller is refused by kind, even on loopback. Operators, the bearer and every caller without sign-in fetch from the org's registered endpoints only (mcpEndpointId, listed by GET /api/mcp-endpoints, whose policy says what the reader may do); the studio's pickers are list-only for them. Every refusal names the way that works for its reader in its posture.

The origin allowlist applies to every target whatever the caller's role — a typed URL, a registered endpoint at each use, and an endpoint when it is registered or its url / readTokenEnv changes. OBSERVOGRAM_MCP_ORIGINS (every org) ∪ OBSERVOGRAM_ORG_<KEY>_MCP_ORIGINS (one org, read only by the org that owns the name): comma-separated origins, no path, query, fragment, credentials or * in a host (a rejected entry is named once on stderr and ignored); a lone * allows every origin; loopback always passes. 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 while a transport hook is loaded (it may attach its own). A server-held token rides only to its own endpoint's registered URL. Without sign-in, only a loopback MCP or a listed origin may be registered at all.

The client (tools/lib/mcp-client.mjs, every caller: the server, npm run fetch-live, the scheduled workflow, journeys) never follows a redirect, redacts every MCP answer text by value (the bearer, URL userinfo and credential-named query values), caps one answer at 32 MiB and honours a caller's AbortSignal.

The token posture. Nobody can register an endpoint there (registration is an admin's; the bearer is an operator), so no server-side MCP fetch works until sign-in is armed (npm run users -- add <login>, no restart) and a signed-in admin registers the endpoint; the bearer then uses it by id.

Migration, in this order:

  1. List every remote MCP origin that receives a credential — an endpoint with readTokenEnv, every deploy target, a transport hook's targets — in OBSERVOGRAM_MCP_ORIGINS (or the org's own variable) before upgrading. A token-less MCP and a loopback one need nothing.
  2. Register, as an admin (Settings → MCP endpoints, or POST /api/mcp-endpoints), every endpoint a CI script or an operator typed.
  3. Send mcpEndpointId in place of mcpUrl from every caller below admin — the write token of a deploy stays the request's mcpAuth. A script that drafts or refreshes against a server without sign-in sends X-Observogram-CSRF: 1 and reaches it on loopback.
  4. Stop relying on redirects: configure the MCP's final URL (http → https and a trailing slash included). A journey captured with a raw URL is re-captured by an admin so its Pack B names a registered endpoint.

15.2 Live jobs, the snapshot and the gate log

A live pack is built as a job (RULINGS R1): POST /api/mcp/jobs answers 202 at once and the client polls the job's gate log. Two kinds: draft (the scaffold POST /api/draft-from-mcp builds, byte for byte) and snapshot (an inventory of what is deployed). The routes (the live MCP API's posture: direct without sign-in, CSRF on the POSTs, closed when exposed):

Route Answer
GET /api/mcp/jobs { ok, scope: { defaults, from, errors }, running, lastTook: { snapshot, draft } }
POST /api/mcp/jobs { kind, mcpEndpointId | mcpUrl, mcpAuth?, scope?, packName?, label? } 202 { ok, job: JobView, poll }, Location: <poll>
GET /api/mcp/jobs/:jobId?since=<seq> { ok, job: JobView, stages: [StageRecord], next, result?, error? }
POST /api/mcp/jobs/:jobId/cancel { ok, job } (its starter: the JobView; an admin of the org: { id, state }, never the log); 409 once ended

JobView = { id, kind, state: running | done | failed | cancelled, cancelRequested, startedAt, finishedAt, elapsedMs, label, target: { mcpEndpoint, origin }, scope }. StageRecord = { seq, stage, label, state: pending | running | done | failed | skipped, counts, startedAt, finishedAt, message, gap: { capability, reason } | null } — every message redacted with the resolved target and cut at 300 characters; send next back as since. Result = { registered: { id, label } | null, validation, counts, gaps, tookMs, draft? } (draft: the draft's summary, conformance and endpoint). A job is its starter's: another member's, another org's, one that expired (15 minutes after it ended) or that a restart lost is the same 404 { ok: false, gone: true, error }. Bounded: one running job per org, four per server, eight finished per org, a 10-minute watchdog, 500 records. Jobs live in the server's memory: a restart loses running and finished jobs (a pack registered before stays). Every end writes one live.fetch audit row.

The stage ids are a contract (LIVE_STAGES, tools/lib/live-fetch.mjs): a downstream maps its own progress names onto them, an id never changes meaning, and a new stage is a new id.

Stage id Label Kinds Capabilities it reads
connect Connect (initialize, tools/list) snapshot, draft —
services Services (health, topology) snapshot, draft system_health, system_topology, anomalies_active, anomalies_baselines
backends Backends and versions snapshot, draft backend_capabilities, grafana_version, build_info_versions, traces_alive
metric_names Metric names snapshot, draft metric_names
recording_rules Recording rules snapshot, draft recording_rules
alert_rules Alert rules snapshot, draft alert_rules
dashboards Dashboards snapshot, draft dashboards, dashboard_detail
scrape_targets Scrape targets snapshot, draft scrape_configs
alerting_routes Alerting routes snapshot, draft alerting_routes
signals Stack signals (self-metrics, Alertmanager, Grafana, rule evidence) draft —
build Build and validate the pack snapshot, draft —
register Register the pack snapshot, draft —
import { stagesFor, fetchPlan, normalizeScope } from './vendor/observogram/live-fetch.mjs';
import { capabilityInventory } from './vendor/observogram/contracts/mcp-capabilities.mjs';
const plan = fetchPlan(capabilityInventory(advertisedToolNames).capabilities, { kind: 'snapshot', complete: true });
// plan: [{ stage, will: 'run' | 'gap' | 'unknown', reason }] — unknown when tools/list was not read whole, never a claimed gap

What a snapshot writes. mcp.url is the MCP's origin only; observogram.live.mode: snapshot, observogram.live.source, observogram.live.scope, observogram.live.gaps; observogram.scope.<kind> for each family the scope narrowed; and observogram.unobserved.<kind> for every family of a gapped stage, with the stage's reason. The diff reads both families of keys: an artefact the snapshot did not look at — outside its scope or in a gapped family — is not checked, never declared, not live (docs/DIFF.md). The catalogue labels the pack (live: 'snapshot', a draft live: 'scaffold') and a snapshot's conformance report says liveKind: 'snapshot' (docs/ADAPTER.md). These are provenance claims like any annotation: a hand-uploaded pack can make them.

The scope. OBSERVOGRAM_SNAPSHOT_METRIC_PREFIXES, OBSERVOGRAM_SNAPSHOT_FOLDER_UIDS, OBSERVOGRAM_SNAPSHOT_DATASOURCE_UID for the deployment and OBSERVOGRAM_ORG_<KEY>_SNAPSHOT_* for one org — the org's overriding the deployment's field by field; a request's scope replaces the configured one whole. Inert when unset; the draft and the refresh never read them. Metric prefixes filter the name list (then a 20 000-name cap, named); folder uids filter dashboards before the detail reads and alert rules when every rule names its folder; a datasource uid is named in the gate log as not applied, because no advertised tool takes one.

Adding a capability with a scope argument. Record the tool's real answer first (npm run record-fixtures -- --write, against the product at a pinned version), then add the candidate to tools/lib/contracts/mcp-capabilities.mjs with a runtimeArgs slot ({ argName: slotName }) the fetcher fills from the scope — the data layer never reads the environment — and its response shape to contracts/response-shapes.mjs; tools/test-contract-shapes.mjs and the contract guard then pin both. No tool name is typed anywhere else.

Retiring a plugin bridge, step by step. (1) Run a snapshot against the same MCP and compare it with the pack the bridge produces (Compare, or diffPacks directly); (2) map the bridge's progress names onto the stage ids above and read the gate log in place of the bridge's own; (3) move the bridge's filters into the scope variables; (4) check each family the bridge fills against the snapshot's observogram.live.gaps — a gap the bridge fills is a reason to keep it; (5) switch the consumers to the snapshot, keep the bridge one release for comparison, then remove it. What this batch cannot replace: a datasource-uid push-down (no capability declares the slot), and parallel dashboard detail reads (the snapshot reads them one at a time). Grafana-managed alert rules are read from the provisioning shape recorded from Grafana itself (the MCP's envelope around it is not yet evidenced).

15.3 The ping

POST /api/mcp/ping { mcpEndpointId | mcpUrl, mcpAuth? } runs initialize, the whole tools/list (up to ten pages) and one cheap read, within 10 s, and answers { ok, verdict, origin, mcpEndpoint, reachable, auth: { outcome, sent }, tools: { count, capabilities, unmatched, complete }, read, timings, sentence, checked, notChecked }. verdict is connected, auth-refused, unreachable, timeout or not-mcp, read from where a failure surfaced. Tool names appear only through capabilityInventory (the capabilities a fetch reads; every other tool is a count). It does not check the backends behind every other tool, whether a fetch would finish within its limits, or — when the read is the Grafana health check — whether the MCP's backend credentials work (read.credentialFree, and notChecked says so). It writes no pack and no live file; an admin's typed-URL ping writes one live.ping row.

15.4 Identity modes

What pairs an artefact of pack A with one of pack B is a mode of tools/lib/identity-modes.mjs (RULINGS R3): behaviour (the default — identityKeyOf, what the artefact does), name (the name or title) or id (a stable id or uid; a family whose name is its id uses the name). Behaviour still decides aligned vs drifted in every mode. A downstream's own diff verification calls the same functions the studio does:

import { diffPacks } from './vendor/observogram/diff.mjs';
import { identityMode, pairingOf } from './vendor/observogram/identity-modes.mjs';
const byName = diffPacks(a, b, { identity: 'name' });          // byName.identity → { mode: 'name' }
const key = identityMode('id').keyOf(artefact, { side: 'a' });   // 'dashboard::{"id":"ord-1"}'
const why = pairingOf(artefact, 'id');                           // 'dashboard uid "ord-1"'

identity may also be a function (artefact, { side }) → '<kind>::…'; a key without the <kind>:: prefix is a TypeError. Omitted, 'behaviour' or identityKeyOf itself, the answer is byte-identical to today's and carries no identity key. An artefact without a name (or a stable id) is keyed with its side and its own artefact id, so it never pairs. The material per family is the table in docs/ADAPTER.md. GET /api/diff takes no identity parameter: the studio re-keys in the browser over the two packs on screen.

15.5 Follow-ups, by name

  • snapshot-dashboard-concurrency — bounded parallel detail reads (it changes the load on the MCP).
  • snapshot-scope-pushdown — capability rows with recorded scope arguments (the datasource uid).
  • artefact-uid-annotation — id mode for Grafana-managed rules (spec 1.4's AlertRule has no uid field, so a rule's id is its name).
  • diff-identity-query (/api/diff?identity=), compare-modes-chains (the traceability graph re-keyed by mode). compare-modes-bundle is closed by the follow-up compare-in-bundle (§10): with Compare answered in the static bundle, Pair by re-keys its diff in the browser as on a server.
  • live-jobs-durable — jobs in the store, so they survive a restart.
  • journey-snapshot-source — re-snapshot on a journey run, with the captured scope.
  • refresh-live-as-job — the rebuild of production-live is still a held request.
  • draft-alert-rule-unobserved, draft-dashboard-ids, draft-tools-list-pages — the draft's own alert-rule gap, dashboard id rule and single-page listing, kept byte-identical.
  • draft-mcp-url-origin — a draft keeps the safe URL with its path in mcp.url (a snapshot keeps the origin).
  • journey-notify-env-ownership (security, pre-existing) — closed by §17: a server-run journey's notification reads only its org's own notify hook, OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL and that hook's _TOKEN; §17.2 is the migration.

16. In-page MCP server settings (rebadge batch 4)

The MCP panel's Server settings… configures the MCP server behind it — the backend base URL, user and secret it reads its backend with, and its own API key — in a modal of the studio page. A downstream that ships a separate configuration page (and the process that serves it) only to POST that configuration can retire it. The contract an MCP server implements is in MCP_INTEGRATION.md, "Server settings (admin configuration)"; the engine is tools/lib/mcp-server-settings.mjs, a listed module, so the MCP server's own tooling can vendor the parser and the path rule. Each change is its own CHANGELOG entry (## Unreleased, "Rebadge batch 4, D1–D4").

16.1 Retiring a sidecar configuration page

  1. Publish the description at <MCP server root>/admin/schema — the MCP URL with its last segment dropped, so http://127.0.0.1:9000/mcp reads http://127.0.0.1:9000/admin/schema — as application/json, without credentials and without current values: { version: 1, endpoint, auth?, fields, actions? }. A server without one gets the generic form (url, user, secret, apiKey to /configure, every field in the body), whose names, path and API-key placement the settings policy's generic block prefills (16.2).
  2. Authenticate the configure endpoint and every action (a bearer the description names in auth, or a field the action sends). Without it, every studio user, every script on the studio's origin and every local process can repoint the MCP server at a backend of their choosing.
  3. Answer CORS for the studio's origin on every response — the description, the configure and each action, errors and 401 included: an exact Access-Control-Allow-Origin and Vary: Origin, never * or null, never Access-Control-Allow-Credentials; the preflight allows POST and content-type, authorization.
  4. Check Origin and Host on the configure POST (a present Origin must be a listed studio origin; the Host one of the server's own bind addresses — the DNS-rebinding guard for a loopback server), never redirect, never echo a submitted secret, validate the backend URL against the server's own SSRF policy, answer the outcome shape { ok, message, checks } and audit the call.
  5. Let the studio reach it. A loopback MCP server is configured from a studio page on the same machine (http://127.0.0.1:<port>); a remote one must be https and listed: OBSERVOGRAM_MCP_ORIGINS (or the org's OBSERVOGRAM_ORG_<KEY>_MCP_ORIGINS) on a server, --mcp-origins baked into a static bundle (§10). Settings carry a credential, so they go only to loopback or a listed origin — never to the studio's own origin.
  6. Or turn on the pass-through (16.3) where the browser cannot reach the MCP server.

Who opens it: whoever may register an MCP endpoint (GET /api/mcp-endpoints policy.register.allowed — a session admin or owner, or the open posture's caller on a direct loopback request). Anyone else sees the button aria-disabled with the reason. On the browser-direct path the studio keeps nothing and records nothing: no value reaches storage, a cookie, the workspace, the store, a log, an audit row or a studio API body, and no studio row records the configure — the MCP server audits its own. The studio's gate, target checks and policy acknowledgements are affordances there; the MCP server's checks (steps 2–4) are what enforce.

16.2 The settings policy

OBSERVOGRAM_MCP_SETTINGS_POLICY=<path.json> (§9) — strict, read once at start, served at GET /api/mcp-settings, baked into a bundle by --mcp-settings-policy:

{
  "version": 1,
  "rules": [
    { "when": { "field": "grafanaUrl", "pattern": "^(?!https://approved\\.)" },
      "warn": "Non-approved backend host.",
      "require": { "ack": "I have operator approval for this target" } }
  ],
  "generic": { "path": "/configure", "names": { "url": "grafanaUrl" }, "auth": "body" }
}
  • when names a field or a type (url, text, boolean — a rule on type: "url" covers a generic form's URL whatever its name). A rule never names a secret. pattern is anchored (^), at most 200 characters, flags "" or "i", with no quantified group and at most one unbounded quantifier — checks of shape, made at start. No start-time probe can prove a pattern fast on every value, so none refuses one: the pass-through evaluates the policy in a worker with a 100 ms deadline per rule, counted from the rule's own start, and a rule that does not finish counts as matched, as do the rules after it (its warning shows, its acknowledgement is required, the server log names the rule and never the value); a worker that cannot start counts every rule as matched, and one that fails every rule it did not finish. A server started with V8 flags (node --max-old-space-size=…) starts the worker without any flags. The studio evaluates the policy in the page, where a slow pattern freezes only that admin's tab. Keep patterns simple: a slow one costs every sender its acknowledgement.
  • Matching. A URL is matched as its normalised form (scheme and host lower-cased; a URL with a user or password before @ is refused, not matched); a value is checked only when non-empty; a value over 512 characters is itself a finding. A rule whose field the form does not have cannot run: it says so and still requires its acknowledgement.
  • warn is shown in the modal; require.ack blocks the send until ticked. Acknowledgements are never remembered — closed and reopened, they are unticked. An action that sends no field the rule matched (disable) skips them; one that carries the matched field waits for the ack, as the pass-through does. Through the pass-through, a rule its re-check names (the 409 below) that the page's check did not flag — one the server could not check, or one it matched — gets its acknowledgement in the modal too, with a line saying which; an action that carries a field the rule checks waits for it as for the page's own (the page's own box, when its check flagged that rule on another field); ticked, the next send carries it, exactly what an API caller's acks attest, and an edit of any value but a secret takes it away (the page's check decides again; a secret, which no rule reads, keeps it).
  • generic prefills the generic form's path, field names and API-key placement (body or bearer); the reader may change them per use and nothing is kept.

How far it reaches. The policy adds friction only: it can never enable a control, lift a refusal or change a target. On the browser-direct path the acknowledgement is advisory — a reader with the browser's developer tools or curl skips it. The pass-through re-checks it against the description the server reads itself. A downstream that needs the rule enforced enforces it in the MCP server.

16.3 The pass-through

OBSERVOGRAM_MCP_ADMIN_PROXY=1 (off by default, read per request) moves every settings request of the modal to the studio server: POST /api/mcp-settings/describe and POST /api/mcp-settings/submit (README "API Surface"). Use it when the MCP server sends no CORS headers or listens where the reader's browser cannot reach it. The contract:

  • admin by class, the CSRF header in every posture, closed when exposed without sign-in, without sign-in only a direct loopback request; off, both answer 404 { denied: 'off' }.
  • The target is a registered endpoint or an admin's typed URL, as on every MCP route; the endpoint's read token is never sent and an mcpAuth is refused; OBSERVOGRAM_ALLOW_LOCAL_MCP, https unless loopback, never the studio's own address, and for a submit the origin allowlist.
  • The paths are the server's: <root>/admin/schema, the endpoint the description declares (read again at submit), or the policy's generic.path (else /configure). The policy is re-checked (a missing acknowledgement is 409 { denied: 'policy-ack', rule }, with unevaluated: true when the server could not check that rule — it did not finish, the worker could not start or failed, it cannot run on the form, or a value it checks is over 512 characters); the modal draws the named rule's acknowledgement (§16.2).
  • One request with the platform's fetch, never through the transport hook: no redirect followed, 10 s, a capped read, exactly Content-Type, Accept and the description's own Authorization.
  • The outcome shape only comes back: the description re-serialised, or the outcome's ok, message and checks redacted — never another body (an HTML 500 is named by status, media type and size). The request body is never logged, kept or echoed; a malformed one is a 400 that quotes none of it. One stderr line per upstream request with status codes only, and one live.mcp-settings audit row per submit (field names and acknowledged rule indexes, never a value).

A static bundle has no pass-through: both routes answer 501 naming "Passing MCP server settings through the studio server" (§10), and its modal sends from the browser.

16.4 Follow-ups, by name

  • malformed-json-app-wide — done (the follow-up that followed this batch): the parse error is answered as JSON on every route, closing the existing quote of a malformed POST /api/mcp/ping body (with its mcpAuth) in Express's error page and on stderr; this batch had fixed it on the two new routes only. Every body the app-wide parsers refuse is now { ok: false, error } with a fixed text and the parser's status — 400 the request body is not valid JSON, 413 too large, 415 an unsupported charset or encoding — no fragment of the body, nothing logged (server/index.mjs bodyParserError; server/test-malformed-json.mjs). The two pass-through routes keep their own parser and answers.
  • settings-current-values — an authenticated read of the server's current non-secret values to prefill the form (version 1 reads no values).
  • settings-policy-target-rules — rules on the MCP origin itself (when: { target: … }).
  • settings-policy-url-part — URL rules on the host or the origin only (when.part).
  • settings-browser-audit-note — a values-free studio note of a browser-direct configure, if wanted (the browser would assert it).
  • settings-endpoint-path-column — a generic path per registered endpoint, if per-endpoint paths turn out to matter.
  • settings-descriptor-v2 — a richer field model (a choice list, validation patterns), as a new version.

17. A journey's notify hooks (follow-up journey-notify-env-ownership)

A journey's notify: names the variables that hold its webhook's URL and token. A journey the studio server runs now reads only its org's own notify hook for them; the CLI keeps reading whatever names its invoker's environment holds (17.3). Before, the server read whatever variable a definition named and posted its value: any operator — or, on a server without sign-in, anyone who could reach it — could have the server post another org's MCP token, or one of the server's own secrets, to a URL of their choosing. Every declared change is in one CHANGELOG entry (## Unreleased, "Follow-up journey-notify-env-ownership"). The rule lives in server/notify-target-policy.mjs (server code, not vendorable); tools/lib/journey.mjs (excluded from the manifest) gains notifyTargetOf, scrubNotifyError and runJourney's resolveNotify option, and tools/lib/schedule-snippets.mjs (listed) takes refusedEnvNames.

17.1 The rule

A hook. A notify hook of org <org> is a pair of variables the server's operator sets in the server's environment: OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL and, optionally, OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_TOKEN, sent as Authorization: Bearer <value>. <KEY> is the org id in capitals with - as _ (pay-eu → PAY_EU; the default org's is DEFAULT only when its id is default — npm run orgs -- list prints the ids); <HOOK> is words of A–Z and 0–9 joined by single _ (ONCALL, PLATFORM_ALERTS); the whole name is upper case exactly and at most 200 characters. A journey names the hook by its URL variable, notify.urlEnv; notify.authEnv is that same hook's _TOKEN, or absent — a hook's token goes only to its own URL.

Whose it is. A variable OBSERVOGRAM_ORG_<KEY>_… belongs to the org whose prefix is the longest match among every org, removed ones included (the read-token rule of README "Services, Environments And MCP Endpoints"). A hook must be its org's alone: a name another org's id also maps onto — an org whose id extends this one's (acme-notify-eu owns OBSERVOGRAM_ORG_ACME_NOTIFY_EU_*; an acme-notify would own every OBSERVOGRAM_ORG_ACME_NOTIFY_*), or a KEY tie (a-b and a_b) — is refused for everyone, and the refusal names no other org. An org whose every hook name another org's id takes (the tie, or an acme-notify beside acme) can have no hook at all, whatever is set: its listing, every refusal of its notify and its schedule snippets (17.3) say so, and that the way out is to leave notify out. Org creation (POST /api/admin/orgs, npm run orgs -- create) refuses an id that would take another org's hooks, whose own hooks another org already takes, or that differs from another org's id only in - and _ (409, naming the other org to the owner who creates it); the orgs-file import is not guarded, and the start's report names each set hook variable such a pair shadows.

What a server run reads. At most those two variables — never another org's, never one of the server's own (OBSERVOGRAM_API_TOKEN, OBSERVOGRAM_SESSION_SECRET, …), never one outside OBSERVOGRAM_ORG_, never the org's MCP token, never packB.mcp.authEnv. The names are judged before any variable is read, so a refused name gets the same sentence whether its variable is set or not. Then the values: the URL must be http(s), without user:password, at most 2000 characters; the token, once its surrounding spaces, tabs and line breaks are trimmed, printable ASCII without spaces (a webhook that needs Basic auth is not supported).

Variable Read by When unset
OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL the start's report, the hooks listing, capture, and a server run of a journey of org <KEY> that names it not listed; capture and a run refuse it
OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_TOKEN the same, for a journey that names it as notify.authEnv listed with tokenEnv: null; a journey that names it is refused
OBSERVOGRAM_NOTIFY_ORIGINS (TOMOGRAPH_NOTIFY_ORIGINS honoured) every org's hooks, at each check with the org's unset too, only loopback is posted to
OBSERVOGRAM_ORG_<KEY>_NOTIFY_ORIGINS org <KEY>'s hooks, at each check, when the org owns the name alone the deployment's list alone applies

The notify origin allowlist. The two lists are unioned and read at each check. Loopback (localhost, 127.0.0.0/8, [::1]) always passes. With a list set, a hook URL's origin must be in it (an entry * admits every origin); a list without an accepted entry admits nothing. With no list, only loopback is posted to: a webhook off this machine needs its origin listed. Entries are origins, scheme://host[:port], comma-separated; a rejected entry is noted once on stderr by its position only — [notify-origins] OBSERVOGRAM_NOTIFY_ORIGINS: entry 2 is a URL, not an origin — list only its scheme://host[:port] — never echoed, because a pasted webhook URL carries its secret. It is a separate list from the MCP one (OBSERVOGRAM_MCP_ORIGINS): an MCP origin is a host the server sends a read token to, a webhook origin a data sink, and listing one never opens the other. An http: origin carries the URL's secret and the token in clear: list one only for a relay on a path you trust. The list compares the URL's origin as a name — a guard against a typo and a confused deputy, not a network policy: *, or a listed name whose DNS someone else controls, reaches whatever address the name resolves to, link-local and metadata addresses included. An address policy belongs to the deployment's egress controls (a Kubernetes NetworkPolicy on the studio pod).

The request. One bounded POST with the platform's fetch — never through the MCP transport hook, with no cookie and no MCP token. A 3xx is a failed delivery with its status and is never followed.

The list a journey chooses from. GET /api/journeys/notify-hooks (every member of the org; no audit row) answers { ok, org, pattern, hooks: [{ hook, urlEnv, tokenEnv, usable, problem }], none }: every set hook URL variable the org owns alone, sorted by hook; tokenEnv the hook's token variable when that is set, else null; usable, or the problem capture would answer; pattern the org's exact prefix; none, when there is no hook, the sentence saying who sets one — or, for an org whose every hook name another org's id takes, no notify hook can be set for org '<org>' — another org's id also maps onto every OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL by the longest-prefix rule, so the server posts no notification for this org's journeys: whoever holds the workspace leaves notify out of them. Never a value, an origin, an origin-list entry or another org's name. No role types a webhook URL in the app — a literal url: stays refused for every role, admins included; a journey names one of the hooks the server's operator registered, as an mcpEndpointId names a registered MCP endpoint. The static bundle answers the route with its 501 "Journeys", as every /api/journeys path.

The start's report (stderr, beside the store's warnings; nothing at all when no hook-shaped variable is set) — variables and org ids only:

  • [notify-hooks] org '<org>': <urlEnv> — <problem> — a set hook a run would refuse (its value or origin);
  • [notify-hooks] <name> has the shape of org '<org>''s notify hook, but org '<other>' also maps onto it by the longest-prefix rule — no journey can use it — never for a name that is another org's own hook (OBSERVOGRAM_ORG_PAY_NOTIFY_NOTIFY_X_URL has pay's shape too, but it is pay-notify's hook X);
  • [notify-hooks] <name> is no org's notify hook (no org's id maps onto it) — (only a removed org's id maps onto it) when that is so.

Capture. POST /api/journeys/capture with a notify that is not one of the org's set, usable hooks answers 400 with the capture sentence and writes no file and no row. The studio's capture sends no notify. A captured file is parsed before it is written and must read back as exactly what was validated (journey <name>: the definition does not read back as written (<key>) — nothing was saved, 400, the earlier file untouched): a pack label's line breaks stay inside one banner comment line.

A run. POST /api/journeys/:name/run of a journey with notify resolves its hook before anything runs, after the Pack B target: a refusal is 400 journey <name> is not run: …, with no record, no row and nothing sent — a refused studio run never becomes the CronJob's next baseline. Without sign-in (OBSERVOGRAM_AUTH=off, or OBSERVOGRAM_INSECURE_NO_AUTH=1 beyond loopback) such a run is answered only to a request sent straight to a loopback address, and never on a server bound beyond loopback: 403 posture, the live MCP API's rule and sentences (… — open the studio at http://127.0.0.1:<port>; … restart it without OBSERVOGRAM_AUTH=off and sign in as an owner, or bind it to loopback). A journey without notify runs as before. GET /api/journeys carries the run's refusal as notify.problem for a journey a run would refuse, and the Neuron view prints it, the run's journey <name> is not run: replaced by its own lead (The server will not run this journey as it stands: …) where it said "Set up", and after the newest record's delivery for a journey that has run. For an org whose every hook name another org's id takes it carries notifyUnavailable, the listing's none, which the studio says where it would name a hook to add.

The sentences. Each names what to fix and who fixes it — the server's operator for the environment, with the restart a running server needs; whoever holds the workspace for the journey file — and never a value, an origin or another org. Below, <P> is the org's prefix OBSERVOGRAM_ORG_<KEY>_, <T> the hook's _TOKEN variable, <J> the journey's file relative to the workspace, in the org's root: journeys/<file>.journey.yaml for an org whose root is ., and orgs/<id>/journeys/<file>.journey.yaml for any other — <file> the name the journey is listed and run by, which its name: need not match — and <L> GET /api/journeys/notify-hooks. Every run sentence starts journey <name> is not run: , <name> the journey's name:.

Case At capture On a run (after the prefix)
a name that is not the org's hook notify.urlEnv "<name>" is not a notify hook of org '<org>' — the server posts a journey's notification only to <P>NOTIFY_<HOOK>_URL (<HOOK>: words of A–Z and 0–9 joined by single _), with its token, if any, from <P>NOTIFY_<HOOK>_TOKEN: name one that <L> lists its notify.urlEnv "<name>" is not a notify hook of org '<org>' — the server posts only to <P>NOTIFY_<HOOK>_URL (<HOOK>: words of A–Z and 0–9 joined by single _), with its token, if any, from <P>NOTIFY_<HOOK>_TOKEN; whoever holds the workspace sets notify.urlEnv in <J> to one that <L> lists (packc journey run reads any name from its own environment)
shadowed notify.urlEnv "<name>" has the shape of a notify hook of org '<org>', but another org's id also maps onto it by the longest-prefix rule, so it is not this org's — name one that <L> lists its notify.urlEnv "<name>" has the shape of a notify hook of org '<org>', but another org's id also maps onto it by the longest-prefix rule — whoever holds the workspace sets notify.urlEnv in <J> to one that <L> lists
any name, where another org's id takes every one of the org's hook names (a KEY tie; <org>-notify beside <org>) notify.urlEnv "<name>" cannot be a notify hook of org '<org>' — another org's id also maps onto every <P>NOTIFY_<HOOK>_URL by the longest-prefix rule, so the server posts no notification for this org's journeys: leave notify out its notify.urlEnv "<name>" cannot be a notify hook of org '<org>' — another org's id also maps onto every <P>NOTIFY_<HOOK>_URL by the longest-prefix rule, so the server posts no notification for this org's journeys; whoever holds the workspace removes notify from <J> (packc journey run reads any name from its own environment)
another token notify.authEnv "<name>" is not the token of the hook notify.urlEnv names — a hook's token goes only to its own URL: name <T>, or leave authEnv out its notify.authEnv "<name>" is not the token of the hook its urlEnv names — a hook's token goes only to its own URL: whoever holds the workspace sets notify.authEnv in <J> to <T>, or removes it
the URL unset notify.urlEnv <urlEnv> is not set in the server's environment — name one that <L> lists, or the server's operator sets it and restarts the server its notify hook <urlEnv> is not set in the server's environment — the server's operator sets it and restarts the server, or whoever holds the workspace sets notify.urlEnv in <J> to one that <L> lists
the token unset notify.authEnv <authEnv> is not set in the server's environment — leave authEnv out, or the server's operator sets it and restarts the server its notify.authEnv <authEnv> is not set in the server's environment — the server's operator sets it and restarts the server, or whoever holds the workspace removes notify.authEnv from <J>
the URL's value <urlEnv> does not hold an http(s) URL without user:password — the server's operator corrects its value and restarts the server (the server sends only Authorization: Bearer from <T>; a webhook that needs Basic auth is not supported) the same
the token's value <authEnv> holds a value the server cannot send as a bearer token (printable ASCII without spaces, once surrounding whitespace is trimmed) — the server's operator corrects it and restarts the server the same
off this machine, no list the webhook in <urlEnv> is not on this machine, and no notify origin is listed, so the server posts only to this machine — the server's operator lists that URL's origin (scheme://host[:port]) in OBSERVOGRAM_NOTIFY_ORIGINS or <P>NOTIFY_ORIGINS, comma-separated, and restarts the server the same
not on the list the webhook in <urlEnv> is at an origin that is not in <the list variables set, joined by ", nor in "> — the server's operator adds that URL's origin (scheme://host[:port]) there and restarts the server the same

What records and rows keep. A delivery error never carries any part of the hook's URL (as read, trimmed or normalised: its origin, host, port, path and query), the address its host resolved to, or its token (as read, trimmed, per line): <redacted>, on the CLI too. A failed lookup or connection reads getaddrinfo ENOTFOUND <redacted>, connect ECONNREFUSED <redacted>, Connect Timeout Error (attempted address: <redacted>, timeout: 10000ms) or Hostname/IP does not match certificate's altnames: <redacted>. GET /api/journeys/:name/runs serves an older record's notify.error with those four texts scrubbed, and undici's two that quote a value (Headers.append: "<redacted>" is an invalid header value., Request cannot be constructed from a URL that includes credentials: <redacted>). The audit rows name the hook only for a journey with notify — journey.capture adds notify: { urlEnv, authEnv }, journey.run adds notify: { urlEnv, status, attempts } — never any part of the URL; a refused capture or run writes none.

MCP read tokens. An MCP endpoint's readTokenEnv may not name a variable with the shape of any org's notify hook (URL or token, of a live or a removed org): it is refused at registration and, for an endpoint registered before, where the token is read, before it is read — 400 `observogram store: has the shape of a notify hook's variable (OBSERVOGRAM_ORG_NOTIFY_URL or _TOKEN), which the server sends only to its own webhook — name another variable of this org (for example

MCP_TOKEN)` (`

MCP_READ_KEY` when `

MCP_TOKEN` is itself a hook's token), followed at use by ` — PATCH /api/mcp-endpoints/ names another variable`. A hook's variables go to its webhook and nowhere else.

A host of its own. A downstream that runs journeys for others with runJourney passes resolveNotify: (notify) => notifyTargetOf(notify, { url, auth }) with the values it resolved under its own rule, so the engine reads no variable a definition names; without it the engine reads process.env[notify.urlEnv] and process.env[notify.authEnv], as the CLI does.

17.2 The migration

Rotate and scrub first: a journey that named another variable has already posted its value every time it notified.

  1. Find every journey that notifies, in every org's root, the ones only the CronJob runs included: grep -nE 'urlEnv|authEnv' <workspace>/journeys/*.journey.yaml <workspace>/orgs/*/journeys/*.journey.yaml (npm run orgs -- list prints each org's root: . is <workspace> itself, orgs/<id> is <workspace>/orgs/<id>). It also lists an authEnv under packB: — Pack B's MCP token (packB.mcp.authEnv), which a server run never reads: that name is outside this rule. Any notify name other than that org's OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL / _TOKEN needs steps 4–8; a name of the server's own (OBSERVOGRAM_API_TOKEN, OBSERVOGRAM_SESSION_SECRET, …), another org's (OBSERVOGRAM_ORG_<other>_*) or the platform's (GITHUB_TOKEN, a cloud credential) needs step 2 as well. The studio flags each such journey (notify.problem on GET /api/journeys, and the Neuron view's line).
  2. Rotate every secret so named. A server run posted the variable's value — to whatever URL the definition paired it with, as the URL or as Authorization: Bearer — every time it notified. In each org's root, grep -h '"urlEnv"' <root>/runs/*/*.json | sort | uniq -c lists every urlEnv the records name, with how many records name it (authEnv is not on records; the definition file names it).
  3. Scrub the records that carry a leaked value: grep -lE 'Headers\.append|cannot be constructed from a URL' <root>/runs/*/*.json, then delete those records or blank their notify.error. The studio serves them scrubbed already, but the files stay on the volume and in any repository the workspace is committed to. A record whose error is a failed lookup or connection (getaddrinfo ENOTFOUND <host>, connect ECONNREFUSED <address>:<port>) names the webhook's host or the address it resolved to, which the studio serves scrubbed too: grep -lE 'getaddrinfo|connect E|Connect Timeout|Hostname/IP' <root>/runs/*/*.json lists them (and any record whose live source failed the same way); treat their notify.error the same way where the host is the webhook's credential.
  4. Choose a hook name per webhook per org (ONCALL, PLATFORM_ALERTS). An org id that equals <another org's id>-notify or starts with <another org's id>-notify- (with - or _, removed orgs included) takes that org's hooks: the start's report names each set hook such an id shadows, and org creation refuses new ones.
  5. Set the hook in the studio's environment from a Secret, and restart: OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL (and _TOKEN). If the org's journeys also run through the CronJob, bind the same names there. Check the start's [notify-hooks] lines and GET /api/journeys/notify-hooks.
  6. List the webhook origins: OBSERVOGRAM_NOTIFY_ORIGINS=https://hooks.slack.com,…, or per org OBSERVOGRAM_ORG_<KEY>_NOTIFY_ORIGINS. Loopback needs no listing.
  7. Rename notify.urlEnv and notify.authEnv in each journey file (whoever holds the workspace). Re-capturing through POST /api/journeys/capture with the new notify also works, but it rebuilds the journey from the session's packs and the body alone (the scope, gate, schedule, stackBudget and notify): a crawl: source, inventory:, keepLivePack and comments are lost. The studio's Save writes no notify at all.
  8. Re-apply the CronJob from the studio's schedule snippet for each renamed journey (Advanced → Neuron, the journey's Schedule block, or GET /api/journeys/<name>/schedule), then remove the old variables from the studio's environment and the CronJob's Secret.
  9. Check: "Run now". It answers the verdict, or 400 naming what to fix. Without sign-in it answers 403 posture unless the request goes straight to a loopback address of a server bound to loopback; that sentence names what to change (17.1, "A run").

17.3 The CLI

packc journey run is unchanged: it reads any name a journey declares from its own environment, an unset one is still exit 2 with no record, and its texts are byte-identical but for the redaction above. Its environment is its invoker's — a shell, a CI job's secrets.*, a CronJob's secretKeyRef bindings — and it cannot know the org: it resolves only the root it is given. The server is one process holding every org's variables and its own secrets, running a file someone else may have captured; that confused deputy is what the rule closes. CI and Actions journeys that name secrets.MY_HOOK_URL keep working.

The shared workspace. The fleet CronJob runs the CLI against an org's root on the volume the studio writes. Capture refuses any name but the org's hooks, a captured file reads back as validated, and the studio's schedule snippets never bind a name the server refuses: GET /api/journeys/:name/schedule moves such a name from envNames to refusedEnvNames (a key present only when non-empty), and each snippet carries, where its binding would have been, # <NAME> is not a notify hook of this org: the studio never reads it — rename it to OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL/_TOKEN before scheduling (docs/DOWNSTREAM.md §17.2) (after REM in the schtasks command) — or, for a notify.authEnv refused beside the org's own hook (any name but that hook's _TOKEN), # <NAME> is not the token of the hook <urlEnv> names: a hook's token goes only to its own URL — set the journey's notify.authEnv to <the hook's _TOKEN>, or remove it, before scheduling (docs/DOWNSTREAM.md §17.2) — or, for one of the org's hook tokens beside a refused notify.urlEnv, # <NAME> is the token of the hook <its hook's _URL> names, not of the journey's notify.urlEnv: a hook's token goes only to its own URL — set the journey's notify.urlEnv to <its hook's _URL>, or remove notify.authEnv, before scheduling (docs/DOWNSTREAM.md §17.2) — or, for one of the org's hook tokens named as notify.urlEnv itself (alone, or as notify.authEnv too), # <NAME> is the token of the hook <its hook's _URL> names, refused as the journey's notify.urlEnv: the studio posts only to a hook's URL — set the journey's notify.urlEnv to <its hook's _URL>, and its notify.authEnv to <NAME> or leave it out, before scheduling (docs/DOWNSTREAM.md §17.2) — or, for any name of an org whose every hook name another org's id takes (17.1, "Whose it is"; no rename can reach a hook there), # <NAME> cannot be a notify hook of this org: another org's id also maps onto every name of its hook shape (OBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL/_TOKEN) by the longest-prefix rule, so the studio posts no notification for its journeys — remove the journey's notify before scheduling (docs/DOWNSTREAM.md §17.1) — or, for a name of the org's hook shape, URL or token, that another org's id also maps onto (17.1, "Whose it is"; it already has the shape a rename would give), # <NAME> has the shape of a notify hook of this org, but another org's id also maps onto it by the longest-prefix rule, so the studio never reads it — set the journey's notify.urlEnv to one that GET /api/journeys/notify-hooks lists, and its notify.authEnv to that hook's _TOKEN or leave it out, before scheduling (docs/DOWNSTREAM.md §17.2). What remains is a file written before the upgrade or edited by hand, run by a CronJob whose Secret holds whatever the file named — 17.2 finds those files and the secrets to rotate. packc journey schedule passes no refusedEnvNames and binds every name the journey declares, as before.

17.4 Follow-ups, by name

  • notify-hooks-picker — a studio control that sets a journey's notify from the listing (it needs a route that rewrites only notify).
  • notify-hooks-store — hooks registered in the app (store rows naming the org's variables, or an admin-typed URL), if in-app registration is wanted.
  • notify-deployment-hooks — hooks every org may use.
  • journey-cli-notify-org — the rule on the CLI when an org is named (--org), which the k8s snippet would set.
  • journey-run-live-posture — the live Pack B run and capture keep their posture for journeys without notify.
  • journey-schedule-mcp-authenv — the schedule route still binds a hand-written packB.mcp.authEnv (capture never writes one); the CLI reads it.
  • mcp-origins-rejected-entry-echo — the MCP origin list names a rejected entry verbatim on stderr; the notify list names positions only, and the MCP list should follow.
  • journey-records-principal — journey records readable only by the member whose run wrote them (today every member of the org reads them, as dashboards).

18. Metrics from prebuilt exporters (follow-up exporter-metrics)

A service whose metrics come from a prebuilt exporter — IBM's mq-metric-samples, a node_exporter, a vendor sidecar — has no client-library call or PromQL that names them: the exporter's repository is a Dockerfile and a config file, and the collector that scrapes it lives in another chart. The crawler (tools/lib/crawler.mjs) reads the exporter's metrics from three places, strongest first, through tools/lib/exporter-metrics.mjs (listed, pure):

Source Files origin_kind Confidence
a metrics manifest metrics-manifest.json, *.metrics-manifest.json exporter-manifest declared
a saved exposition *.prom, *.metrics (never under a test, fixture or mock folder; an OpenMetrics counter or info named as stored, _total / _info) exporter-snapshot declared
the exporter's config, read against the catalogue any YAML whose top-level keys match a catalogue entry exporter-config inferred (a candidate)

A metric named in several files reads as the strongest: manifest, snapshot, source code, source fragment, recording-rule output, PromQL reference, and an exporter config last. Every exporter the crawl learned from is listed in crawler.discovered.exporters ({ <exporter>: { sources: [{ origin_kind, file, metrics, … }] } }) and counted in crawler.discovered.exporters_count. A manifest and a snapshot are read in every environment; an exporter config is scoped like any other config, and a crawl whose environment scope leaves out a file with scrape jobs or exporter evidence says which in a warning.

18.1 The manifest

{
  "exporter": "ibm-mq",
  "version": "5.6.0",
  "source": "http://mq-exporter:9157/metrics",
  "generatedAt": "2026-10-01T00:00:00.000Z",
  "metrics": [
    { "name": "ibmmq_queue_depth", "type": "gauge", "help": "Current depth of the queue", "labels": ["qmgr", "queue"] }
  ]
}

exporter and metrics are required; each entry needs a metric name and an exposition type (counter, gauge, histogram, summary, untyped, unknown, gaugehistogram, info, stateset). An entry that has neither, or repeats a name, is not read and the crawl says how many; a file that is not a manifest is named in a warning. source is recorded without userinfo, query or fragment: tabs and line breaks, everything before the last @, and everything from the first ? or # are dropped (safeSourceUrl, textual — a password may hold / or @). A value with a ? or # before its last @ is not recorded at all. Label values are never part of a manifest.

packc emit --exporter <url> (tools/emit-manifest.mjs) writes one from a live endpoint — run it in the exporter's build pipeline against the image it just built:

packc emit --exporter http://mq-exporter:9157/metrics --name ibm-mq --version 5.6.0 --out mq-exporter/metrics-manifest.json
packc emit --from saved.prom --name ibm-mq --out -     # a saved exposition, to stdout

It scrapes once and records each family's name, type, help and label names. A redirect is refused, not followed: the refusal names its target with the query as the keys of its key=value parts only (?token=…) — pass that URL with the values the exporter expects. A URL with credentials is refused (save the exposition with your own client and pass it with --from). A status other than 200 or a response with no metric family exits 1.

18.2 The catalogue

An exporter config names no metric; the catalogue says which families a config switches on. The built-in catalogue (tools/lib/exporter-catalogue.mjs, data only, EXPORTER_CATALOGUE) carries a small IBM MQ entry. A downstream carries a larger one as a JSON file of the same shape, never as an engine diff:

{
  "exporters": [
    {
      "id": "ibm-mq",
      "match": { "keys": ["global", "connection", "objects", "prometheus"] },
      "namespace": { "path": "prometheus.namespace", "default": "ibmmq" },
      "gates": {
        "objectStatus": { "path": "global.useObjectStatus", "default": true },
        "publications": { "path": "global.usePublications", "default": true }
      },
      "scopes": { "queues": { "path": "objects.queues" } },
      "families": [
        { "name": "queue_depth", "type": "gauge", "help": "Messages on the queue", "labels": ["qmgr", "queue"], "gate": "publications", "scope": "queues" }
      ]
    }
  ]
}
  • match.keys — the top-level keys a config must all carry. The first entry that matches reads the file.
  • namespace — the metric-name prefix: the value at path in the config, else default; a family's name is <namespace>_<name>.
  • gates — boolean switches (true/false, yes/no, on/off), the default when the config is silent; a family with gate is emitted only when it is on.
  • scopes — object selections; a family with scope is emitted only when the value at path selects something (a non-empty list, string or mapping).

Pass it with packc x-ray <repo> --exporter-catalogue <file.json> or crawlFiles(files, { exporterCatalogue }). Its entries replace a built-in entry with the same id and add the rest. An entry that is not well-formed is dropped with a crawl warning naming what it lacks. A config-inferred family stays a candidate (confidence: inferred): the config says the exporter is switched on, not that the name is exact. Publish the real list with packc emit and the manifest outranks it.

18.3 The collector's scrape jobs

An OTel collector's prometheus receivers (prometheus, prometheus/<name>) are read for their config.scrape_configs, with or without a service: block (a Helm chart's files/ copy has none), in a config file, a Helm ConfigMap, or a chart's values*.yaml (at its top level, under config:, or under a subchart's <key>.config: unless the subchart is disabled — its enabled toggles merged across the in-scope values files, an overlay winning).

  • Each job is in crawler.discovered.scrape_jobs. Its entry in crawler.discovered.scrape_job_origins adds via: otel-collector, the receiver, the target_count of its static targets, and the exporters its samples leave by: the exporters of the metrics pipelines that list the receiver, in the same file or, when none there does, another (a config split across --config files), or, when none does, the metrics exporter a pipeline-less config names. A job that cannot be read is warned about and skipped; the rest of the config is read. Each exporter is given as { name, kind, topic | endpoint }: a kafka exporter's metrics topic, any other's endpoint reduced by safeSourceUrl (no userinfo, query or fragment).
  • That exporter's kind is spec.pipelines.exporters.metrics, in place of the prometheusremotewrite scaffold. A config with no pipelines only proposes one: it applies after every file is walked, and only when nothing else set the metrics exporter — no pipeline in any file declares one, and no metrics backend image implies one. When it names several, the first is taken and the crawl warns.