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.
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.
A module is listed when, mechanically:
- it imports no
node:*module, reads noprocess(but for exactly the two guarded formsglobalThis.process?.cpuUsage?.()andglobalThis.process?.platform—PROCESS_READS_ALLOWED: the CPU time and the platformartefact-classify.mjs's id clocks read where the runtime has them; a browser readsundefined, and the wall clock decides), touches no DOM or web storage (document,window,navigator,localStorage,sessionStorage), reads noimport.metaand calls norequire()— judged on code, never on comments or string text; - every relative import resolves to another listed module (the import graph
stays inside the set) and every bare import is a declared
package.jsondependency; - its export forms are plain (
export const|let|var|function|classandexport { … }lists;export defaultandexport *make generation fail so support is added deliberately); - it is not in
EXCLUDEDintools/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).
- Pick the upstream tag or commit and record it next to your copy (a
UPSTREAMfile, a lockfile comment — anything you will read again). - Copy
VENDOR-MANIFEST.jsonandtools/gen-vendor-manifest.mjsinto your tree. The script imports node built-ins only and nothing fromtools/lib, so it runs anywhere beside the manifest. - Copy the listed modules you need with the modules their
importsname, preserving thetools/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 (--verifysays so). - Install the npm packages the
npmfields name, at the versions upstream'spackage-lock.jsonpins (a static bundle build refuses any other, §10).
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.
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.
- Diff the old and the new
VENDOR-MANIFEST.json. Per module,exportsadded or removed is an API change;removedSinceReleaseand theexcludedtable list what left the set; a module whoseexportsare unchanged while itssha256moved is a behaviour update, not an API break.changedSinceReleasein the new manifest tells you what moved since its release; between two releases, read the release section ofCHANGELOG.mdfor every module the diff names — upstream names each one there. - Re-copy the modules (step 3), re-apply your overlay, run
--verifyand--smoke(steps 4 and 5), then your own tests. - Record the new upstream tag or commit.
- The exports of a listed module change only with a
## Unreleasedentry indocs/CHANGELOG.mdthat names the module (path or backticked basename);tools/test-vendor-manifest.mjsfails 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).
- Server routes, studio views and the store: the manifest is the library contract, not an API contract.
- The zero-import studio modules
VENDORING.mdlists (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 letretrofeed.mjsenter the set) is a follow-up. - The npm packages a static bundle carries beyond the
npmfields' roots (@lezer/lr,@lezer/highlightand@lezer/common, which@prometheus-io/lezer-promqlimports): the manifest names the roots; every package a build embeds is listed by the bundle itself — its licence block and the builder's--jsonthirdParty(§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.mdtracks 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).
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 devThe 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.
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.htmlEach 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.
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.
--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.
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.mjsandtools/test-bundle-compare-pairs.mjscompare it with a running server in Node, from the source (every ordered pair of ten packs in each scope mode the studio sends; everyscopeModespelling 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 owndata:address), in everynpm test, CI's included. Its C8 compares the in-page answer in headless Chromium under the browser localesen-US,cs-CZandet-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, orurlfor a--pack-urlpack),assessmentsandwaivers(available: false, "not recorded by this build": a bundle has no store — itsGET …/verdictsstill 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'sgeneratedAt, and for a pack a server has registeredtier.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.mjsholds 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:
- Snapshot the whole tree as before; keep those four files from the same upstream commit as the rest.
- Run
npm ciat the tree's root (Node 22.16 or newer) beforenode 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 withnpm cionline, then runnpm ci --offline). - Build. The human summary line is unchanged;
--jsongains a last key,thirdParty— each embedded package'sname,version,license,entry(its file inside the package) andbytes; no build-machine path. - 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.
- A pipeline that rewrites the HTML after the build (a minifier, a CDN
optimiser) must keep the
#observogram-licenseselement; a step that only strips comments keeps it, since it is a script element.verifyBundleNotices(html), exported bytools/build-studio-bundle.mjs, checks a finished file. - 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-fontsneeds no change. - Nothing else: no other flag, config key, Content-Security-Policy or host
change (
data:inscript-srcwas 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.mjsvendors it and the block embeds it.bundle-licences-view— the licences readable from the studio's About entry.bundle-discover-controls— Discover'sScan a repoandGenerate live from MCPdrawn unavailable in the static posture (they answer the501sentence on use).server-third-party-notices— one notices file for the server's own npm closure in the image.--split— a directory form withoutdata:URLs, for a host whose Content-Security-Policy forbids them inscript-src.
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.
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 crawlreads 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.
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 remainspackc 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.
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.
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.
- It validates against the vendored spec or exits 3 — and exit 3 is a bug to report, never an input error.
- Flags that cannot yield a valid pack are refused before the crawl
(exit 2, the rule in the message, nothing on stdout):
--name,--envand each--ownersentry must be spec Slugs,--criticalityand--bindingspec 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 andcrawler.criticalityIgnored, an unknown binding the default andcrawler.bindingIgnored. - Names are normalized to the spec Slug and the original kept: the folder
name in
crawler.nameNormalizedFrom, the environment incrawler.environmentNormalizedFrom; owners only in the response'ssummary.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. - What the spec cannot hold is omitted and recorded as evidence, never
invented and never silently dropped: recording rules not named
<service>:<metric>:<op>incrawler.omitted.recording_rules(name +<file>#<group>/<name>; their expressions still feed the metric inventory), a dashboardschemaVersionbelow 30 inparams.schema_version, a rule-group interval that is not a Duration in the response'ssummary.omitted.ruleIntervals. - 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 conformantNamed 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.mjsapplied by the crawler and the fetcher plus a diff test; marking one side alone would move every crawled SLO out of Compare'sinBoth. - Artefact-level
otelscaffold 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-?ratesuffix handling inburnCandidateFromAlertRule— a hyphenated alert name yieldsshop-availability_99while the recording-rule derivation yieldsshop_availability_99; unifying the separator renames L1 ids on both producers.javaMetricPrefix'ssolace_product rule — kept as a product rule (the path or the source literally sayssolace); a generic prefix rule read from configuration is the follow-up.legacy.liveness.mcpUrlthroughstripMcpUrl— 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 honoursopts.waiversand answers thewaivedpartition;packc conformance --waivers <file>drives it. - Service audit report (GAP batch 2, B3.5) — delivered (§14
service-audit-report): the report's placeholders section ispackConformance(canonical)'s rows, andGET /api/packs/:id/placeholdersanswers the same rows (the static bundle too). - Imports as Scaffold — the adapter hardcodes
source: 'Declared'formetadata.imports, so the upconvert'slegacy/<slug>pseudo-refs are reportedunmarkedand never parked; a board change.
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.jsonare protected by their directories' ACLs: Windows does not enforce0600. Limit the workspace directory (and the database's, whereOBSERVOGRAM_DBputs 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/journeysandpackc journey run --allfollow 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 readmeNODE_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.
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 |
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 (curlin 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--packneeds 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 (serviceTierForalready returns the environment as the hook).B3.2-supersedes— a renewal is a new row after an expiry; asupersedeslink from the renewal to the lapsed waiver would keep the chain readable in the audit.
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 bysync-spec), after which the annotation becomes the fallback.remediation-flow-graph-unify— the traceability graph infers its ownremediatesedges 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 andtools/test-remediation-flow.mjspin the catalogue), to land with a maintainer's eye on each pack.
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 averdicts.jsoninto 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— nopackc verdictsyet: the API is the one door (curlin 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 flaggedorphaned: true, counted insummary.orphaned); a migration-time re-key by identity key is the fix when such an upgrade ships.
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.mjsGLOSSARY) and the operator's glossary are two voices; letting a v2 entry override atermHtmlkey'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.mjspins the spec, so the seed is a tool, not an edit) would give every downstream a starting file.
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-upcompare-in-bundle(§10): the bundle inlines the PromQL parser, and the shim builds the report in the browser withgoesBlind.available: true, its verdicts and waivers sections "not recorded by this build".B3.5-export-zip—GET /api/packs/:id/export.zipcould carryaudit-report.json/.htmlbesideverdicts.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; aprefers-color-schemedark variant and a print stylesheet beyond@media printbasics wait for a downstream that prints or embeds it.
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).
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:
- List every remote MCP origin that receives a credential — an endpoint with
readTokenEnv, every deploy target, a transport hook's targets — inOBSERVOGRAM_MCP_ORIGINS(or the org's own variable) before upgrading. A token-less MCP and a loopback one need nothing. - Register, as an admin (Settings → MCP endpoints, or
POST /api/mcp-endpoints), every endpoint a CI script or an operator typed. - Send
mcpEndpointIdin place ofmcpUrlfrom every caller below admin — the write token of a deploy stays the request'smcpAuth. A script that drafts or refreshes against a server without sign-in sendsX-Observogram-CSRF: 1and reaches it on loopback. - 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.
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 gapWhat 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).
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.
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.
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'sAlertRulehas 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-bundleis closed by the follow-upcompare-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 inmcp.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>_URLand that hook's_TOKEN; §17.2 is the migration.
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").
- Publish the description at
<MCP server root>/admin/schema— the MCP URL with its last segment dropped, sohttp://127.0.0.1:9000/mcpreadshttp://127.0.0.1:9000/admin/schema— asapplication/json, without credentials and without current values:{ version: 1, endpoint, auth?, fields, actions? }. A server without one gets the generic form (url,user,secret,apiKeyto/configure, every field in the body), whose names, path and API-key placement the settings policy'sgenericblock prefills (16.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. - 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-OriginandVary: Origin, never*ornull, neverAccess-Control-Allow-Credentials; the preflight allowsPOSTandcontent-type, authorization. - Check
OriginandHoston 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. - 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'sOBSERVOGRAM_ORG_<KEY>_MCP_ORIGINS) on a server,--mcp-originsbaked 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. - 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.
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" }
}whennames afieldor atype(url,text,boolean— a rule ontype: "url"covers a generic form's URL whatever its name). A rule never names a secret.patternis 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. warnis shown in the modal;require.ackblocks 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'sacksattest, 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).genericprefills the generic form's path, field names and API-key placement (bodyorbearer); 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.
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:
adminby class, the CSRF header in every posture, closed when exposed without sign-in, without sign-in only a direct loopback request; off, both answer404 { 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
mcpAuthis 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'sgeneric.path(else/configure). The policy is re-checked (a missing acknowledgement is409 { denied: 'policy-ack', rule }, withunevaluated: truewhen 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, exactlyContent-Type,Acceptand the description's ownAuthorization. - The outcome shape only comes back: the description re-serialised, or
the outcome's
ok,messageandchecksredacted — 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 onelive.mcp-settingsaudit 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.
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 malformedPOST /api/mcp/pingbody (with itsmcpAuth) 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.mjsbodyParserError;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.
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.
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_URLhaspay's shape too, but it ispay-notify's hookX);[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.
Rotate and scrub first: a journey that named another variable has already posted its value every time it notified.
- 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 -- listprints each org's root:.is<workspace>itself,orgs/<id>is<workspace>/orgs/<id>). It also lists anauthEnvunderpackB:— Pack B's MCP token (packB.mcp.authEnv), which a server run never reads: that name is outside this rule. Anynotifyname other than that org'sOBSERVOGRAM_ORG_<KEY>_NOTIFY_<HOOK>_URL/_TOKENneeds 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.problemonGET /api/journeys, and the Neuron view's line). - 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 -clists everyurlEnvthe records name, with how many records name it (authEnvis not on records; the definition file names it). - 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 theirnotify.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/*/*.jsonlists them (and any record whose live source failed the same way); treat theirnotify.errorthe same way where the host is the webhook's credential. - Choose a hook name per webhook per org (
ONCALL,PLATFORM_ALERTS). An org id that equals<another org's id>-notifyor 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. - 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 andGET /api/journeys/notify-hooks. - List the webhook origins:
OBSERVOGRAM_NOTIFY_ORIGINS=https://hooks.slack.com,…, or per orgOBSERVOGRAM_ORG_<KEY>_NOTIFY_ORIGINS. Loopback needs no listing. - Rename
notify.urlEnvandnotify.authEnvin each journey file (whoever holds the workspace). Re-capturing throughPOST /api/journeys/capturewith the newnotifyalso works, but it rebuilds the journey from the session's packs and the body alone (the scope,gate,schedule,stackBudgetandnotify): acrawl:source,inventory:,keepLivePackand comments are lost. The studio's Save writes nonotifyat all. - 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. - Check: "Run now". It answers the verdict, or 400 naming what to fix.
Without sign-in it answers 403
postureunless the request goes straight to a loopback address of a server bound to loopback; that sentence names what to change (17.1, "A run").
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.
notify-hooks-picker— a studio control that sets a journey'snotifyfrom the listing (it needs a route that rewrites onlynotify).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 withoutnotify.journey-schedule-mcp-authenv— the schedule route still binds a hand-writtenpackB.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).
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.
{
"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 stdoutIt 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.
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 atpathin the config, elsedefault; a family's name is<namespace>_<name>.gates— boolean switches (true/false,yes/no,on/off), thedefaultwhen the config is silent; a family withgateis emitted only when it is on.scopes— object selections; a family withscopeis emitted only when the value atpathselects 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.
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 incrawler.discovered.scrape_job_originsaddsvia: otel-collector, thereceiver, thetarget_countof its static targets, and theexportersits 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--configfiles), 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 bysafeSourceUrl(no userinfo, query or fragment). - That exporter's kind is
spec.pipelines.exporters.metrics, in place of theprometheusremotewritescaffold. 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.