Some teams build their own studio around Observogram's scoring engines — a
different shell, a different stack (e.g. a TypeScript "ADO studio"), but the
same verdict semantics. Reimplementing the grade engine is the fastest way to
drift from it, so the supported path is to vendor the engine files verbatim
(e.g. into a packages/upstream/ directory downstream) and re-copy them when
this repo moves.
This document is the contract that makes that safe: which files are vendorable, what each one needs from the host, and which seams this repo promises to keep stable.
Since 2026-10 the tools/lib part of this contract is machine-readable:
VENDOR-MANIFEST.json lists every pure module
under tools/lib a downstream may copy — sha256, byte size, exported names,
intra-set imports, npm dependencies — with the package version it belongs to
and the modules that are excluded, each with its reason. It is generated by
npm run vendor-manifest, guarded by tools/test-vendor-manifest.mjs in
npm test, and DOWNSTREAM.md is the workflow built on it
(snapshot → verify hashes → smoke → bump). The tools/lib rows of the table
below are a subset of the manifest; the table keeps the studio rows and the
prose contracts that follow.
| Module | Dependencies | Notes |
|---|---|---|
tools/lib/diff.mjs |
imports artefact-model.mjs, live-fetch.mjs, identity-modes.mjs — copy the three together |
pack arithmetic — the diff buckets everything downstream consumes; diffPacks(a, b, { scopeMode, service, identity }) (an identity mode or key function; behaviour the byte-identical default), and a snapshot's observogram.scope.<kind> parked as not checked |
tools/lib/identity-modes.mjs |
imports artefact-model.mjs, artefact-classify.mjs; no Node APIs |
comparison identity modes (rebadge batch 3, R3) — IDENTITY_MODES (behaviour = identityKeyOf, name, id), DEFAULT_IDENTITY_MODE, identityMode, nameOf, idOf, nameKeyOf / idKeyOf ((artefact, { side })), pairingOf, identityFamilies; the studio's Compare loads the same file |
tools/lib/live-fetch.mjs |
zero-import, no Node APIs | the live fetch's contract (rebadge batch 3) — LIVE_STAGES (the stage ids, docs/DOWNSTREAM.md §15.2), stagesFor, stageNoun, fetchPlan, normalizeScope, scopeAnnotations, scopeOf, inScope, scopeReason, SCOPE_LIMITS, SNAPSHOT_LIMITS |
tools/lib/mcp-server-settings.mjs |
imports artefact-classify.mjs, mcp-url-safety.mjs — copy the three together; no Node APIs |
the MCP server-settings contract (rebadge batch 4) — the descriptor v1 an MCP server serves at <root>/admin/schema (SETTINGS_DESCRIPTOR_VERSION, SETTINGS_DESCRIPTOR_PATH, SETTINGS_LIMITS, FIELD_TYPES, parseSettingsDescriptor), the root and path rule (settingsRoot, resolveSettingsPath, normaliseUrlValue), the generic form (genericDescriptor, GENERIC_NAMES), the request (settingsRequest), the outcome and its redaction (outcomeOf, redactEchoes, redactSecretKeys) and the settings policy (compileSettingsPolicy, policyFindings); an MCP server author vendors it to validate a descriptor or policy file, and the studio's Server settings modal loads the same file |
tools/lib/artefact-model.mjs |
imports promql-canon.mjs, artefact-classify.mjs |
behavioural identity + contract projection — identityKeyOf, behaviorOf, deltasOf, classify (delegates to familyOf) |
tools/lib/artefact-classify.mjs |
zero-import; no Node APIs but the id clocks' guarded globalThis.process?.cpuUsage?.() and globalThis.process?.platform (a browser reads undefined: the wall clock decides) |
the artefact taxonomy — FAMILIES, FAMILY_HOME, ID_RULES, classifyArtefact (type → defines → override ids → id prefix), compileBoundedPattern (the bounded-pattern rule the taxonomy and the settings policy share), compileTaxonomy / configureTaxonomy for the operator override (schema v1 or v2 — TAXONOMY_VERSIONS, TAXONOMY_VERSION_LATEST; TAXONOMY_VERSION stays 1), and the v2 glossary's accessors glossaryFor, glossaryByText, glossaryEntries (GLOSSARY_LIMITS) |
tools/lib/brand.mjs |
zero-import, no Node APIs | the brand config — DEFAULT_BRAND, normalizeBrand, brandChrome, brandTokensCss, brandShellHtml; a downstream studio reads the same object its server injects (#brand-config) |
tools/lib/promql-canon.mjs |
imports promql.mjs |
parser-proven PromQL canonicalisation |
tools/lib/promql.mjs |
pure ESM, no Node APIs | PromQL tokeniser/dependency reader |
tools/lib/protocols.mjs |
pure data | the versioned protocol/feature canon |
tools/lib/pack-conformance.mjs |
imports waivers.mjs (which imports conformance.mjs); no Node APIs |
the placeholder report — packConformance (a live snapshot's report says liveKind: 'snapshot'), scaffoldMarkers, parseSymbol / resolveSymbol, SYMBOL_FAMILIES, PLACEHOLDER_FIELDS; SCAFFOLD_PREFIXES is a pinned copy of the adapter's three marker prefixes |
tools/lib/waivers.mjs |
imports conformance.mjs |
waivers (GAP batch 2) — normalizeWaiver, waiverState / expiresInDays / waiverView, matchesFinding, applyWaiversToFindings (the findings seam), applyWaiversToConformance (the overlay: the same report object without an open waiver), readWaiverFile (the sidecar) |
tools/lib/remediation-flow.mjs |
imports artefact-model.mjs (classify, identityKeyOf) |
the diagnose → remediate flow (GAP batch 2) — buildRemediationFlowModel({ pack, diff, liveAnnotations, otherSide }): a remediation's trigger resolved to its alert artefacts (observogram.remediates.* annotation, rule name, compiled burn-rule name, SLO — resolveTrigger, remediationTargets, burnRuleNames, slugKey / triggerSlug), the alerts' states from the comparison (alertStatesFromDiff), the next steps; pure, no clock, never throws |
tools/lib/audit-report.mjs |
imports artefact-classify.mjs, blast-radius.mjs, brand.mjs, pack-conformance.mjs, remediation-flow.mjs; no Node APIs |
the service audit report (GAP batch 2) — buildAuditReport (the document), the section builders, renderAuditReportHtml (the first standalone document over design-tokens.css + design-kit.css), auditReportFilename; the traceability graph arrives as a shape |
tools/lib/legacy.mjs |
imports pack-conformance.mjs |
the layered-JSON upconvert and the merge — isLegacyLayeredPack, upconvertLegacyPack (→ { canonical, report, provenance }), mergeUpconvert; copy the two together |
tools/lib/stack-evidence.mjs |
imports contracts/stack-self-metrics.mjs only |
pure history helpers over journey run records — stackSeries, latestByFamily, stackSummary, stackPostureBudget, nonzeroRuns, formatStackValue, stackOutcomeLabel; explicit inputs, no Node APIs, every output a point-in-time signal |
tools/lib/contracts/stack-self-metrics.mjs |
pure data + lookups | the stack self-metric alias table stack-evidence.mjs orders by — copy the two together |
tools/lib/blast-radius.mjs |
zero-import (CI-asserted) | the blind-spot blast radius over the requirement graph — CONSUMER_SIDE / PROTECTION_SIDE, normalizeGraphShape, blastRadiusOf, blastRadiusIndex; input is the plain { nodes: [{ key, identityKey, kind, layer, label, virtual, scaffold }], edges: [{ from, to, type, provenance }] } that graphShape(graph) in traceability-graph.mjs projects (nodes also accepted as a Map or a keyed object); structural exposure only — what WOULD go blind — it never reads liveness |
tools/lib/chain-history.mjs |
zero-import (CI-asserted) | requirement-chain verdicts over time — branchRecordsFromGraph over a compareBranches result, then chainSummary, diffRunBranches, rankCauses({ previous, current, deploys, baseline? }), deploysInWindow, topCause over journey run records ({ startedAt, outcome, branches, versions, stackEvidence, probes, … }) and deploy audit lines, plus the vocabulary tables the ranker reads (CAUSE_KINDS, CAUSE_SCORES, FAMILY_FOR_KIND / familyForKind, PRODUCT_FAMILIES / familiesForProduct, DEPLOY_GROUP_KINDS, deployArtifactNames); a host that holds the declared pack resolves deploy selectors itself and passes them as item.resolved (the way journey.mjs does); nothing here scores, every function is pure and never throws |
studio/diagnostic-grade.mjs |
zero-import (CI-asserted) | the grade engine: coverage/trust criteria, posture matrix, weighted delta risk, instrument-grade scale |
studio/artifact-model.mjs |
zero-import (CI-asserted) | behavioural identity + deploy-surface model per artefact family |
studio/constants.mjs |
pure data | the display vocabulary (layers, domains, grade banding) |
studio/verdict-ui.mjs |
see below | the normalized verdict model, grade projection, and widget/honesty blocks |
studio/verdict-ui.css |
host theme's CSS custom properties (listed in its header) | the styles for the widgets verdict-ui.mjs emits |
studio/compare-catalog.mjs |
state.mjs only |
catalogEntryFor() + LAYERS_FOR_DIFF |
studio/host.mjs |
zero-import | the studio-wide host seam: initHost() + the live host object |
studio/proto-synthesis.mjs |
host-injected callbacks | the ratified Diagnose/Remediate synthesis view |
studio/design-tokens.css |
zero-dependency (CI-asserted) | the design tokens: palette, type and measures as --og-* custom properties, light and dark — see The design system below |
studio/design-tokens.json |
generated from the CSS (CI-asserted identical; node tools/gen-design-tokens.mjs --brand <file.json> --out <path> writes a rebadged copy — tools/lib/brand.mjs token overrides applied, never over this default) |
the same values for code that cannot read CSS (a TypeScript theme object, a chart palette) |
studio/design-kit.css |
design-tokens.css only (CI-asserted) |
the component kit: .og-btn, .og-panel, .og-stat, .og-pill, .og-steps, .og-input, .og-callout, .og-letter … |
studio/design-bridge.css |
design-tokens.css only (CI-asserted) |
defines the theme properties verdict-ui.css and proto.css expect from the host (--card, --ink, --mono, --CMP, --pr-green …) from the tokens |
studio/verdict-html.mjs |
escapeHtml from util.mjs; statusChipHtml / statusRecord from ux-kit.mjs |
the pure HTML of a reviewer's verdict (GAP batch 2): verdictBadgeHtml, verdictChipHtml, verdictFilterHtml, passesVerdictFilter, VERDICT_STATUSES, VERDICT_FILTERS; null renders '' — copy it with the board |
studio/glossary.mjs |
escapeHtml from util.mjs; glossaryFor / glossaryByText / familiesAt from taxonomy.mjs |
the glossary marks (GAP batch 2): glossaryMarkFor, glossaryLabelHtml (=== escapeHtml(text) with no entry), glossaryGroupMarkHtml, wireGlossary(root), resetGlossaryIds, stripGlossaryMarks; nothing is drawn without a v2 glossary — copy it with the board |
studio/discover-board.mjs |
escapeHtml from util.mjs; classifyArtefact / requireTaxonomy from taxonomy.mjs; verdictBadgeHtml from verdict-html.mjs; glossaryGroupMarkHtml / glossaryLabelHtml from glossary.mjs |
the pack board: boardHeadHtml, boardGroupsHtml, boardGroups, BOARD_LAYERS — pure HTML builders over an adapted pack, grouping by the family's home (FAMILY_HOME); bindTaxonomy() must have run before boardGroups (it throws taxonomy unbound: … otherwise); its styles are still in ux-discover.css (the .dv-band* / .dvb-* rules) and reskin.css |
studio/taxonomy.mjs |
zero-import; bound at boot to tools/lib/artefact-classify.mjs |
the studio's taxonomy binding: bindTaxonomy(mod, json), classifyArtefact (degrades to via: 'unbound' before boot), requireTaxonomy; the typeof-guarded glossary passthroughs glossaryFor, glossaryByText, familiesAt(layer, group) (an older classifier reads as the empty glossary) |
studio/static-backend.mjs |
tools/lib adapter, service-keys, validator, conformance, pack-conformance, compile, zip, mini-yaml, library, brand (normalizeBrand, DEFAULT_BRAND), artefact-classify (activeTaxonomy, compileTaxonomy); at a route's first call, by literal dynamic import, diff, traceability-graph (and through it the npm package @prometheus-io/lezer-promql, which only the bundle's import map resolves), audit-report and retrofeed; studio/focus.mjs (the focused pack for Export), studio/brand.mjs (readBrandConfig) |
the static bundle's backend: createStaticBackend(config, { fetchImpl, origin, brand, product, styles, clock, importDiffEngines, importRetrofeedEngines, importAuditEngines }) answers the pack routes and Compare's diff, retrofeed and the audit report from these engines in the browser and /api/taxonomy from the baked document (config.taxonomy, written by --taxonomy) — the three import…Engines options are test seams, clock stamps the report and retrofeed's adoptedAt, styles is the report's design kit; bundleStyles(textOf) and REPORT_STYLESHEETS read that kit from the page's inlined stylesheets; installStaticBackend(config, win) wraps the page's fetch and names the product from #brand-config (written by --brand); tools/build-studio-bundle.mjs is the reference host of this table's modules — one file that runs them without the server |
Rebadge batch 3 (live-fetch UX). Besides the two new modules above, these
listed modules changed — each named in the CHANGELOG's ## Unreleased:
tools/lib/contracts/mcp-capabilities.mjs gains capabilityInventory(toolNames)
(an MCP's advertised tools mapped to the capabilities Observogram reads);
tools/lib/service-keys.mjs gains livePackKind(canonical) (snapshot /
scaffold / null, and the catalogue's live); tools/lib/crawler.mjs gains
dashboardSpecId(dash) (the one dashboard id rule a crawled pack and a
snapshot share); tools/lib/mcp-client.mjs (behaviour) refuses redirects,
redacts every answer text by value, takes a caller's signal and caps an
answer at MAX_MCP_ANSWER_BYTES (32 MiB). tools/lib/journey.mjs (excluded:
the server-side journey runner) gains validatePackBMcp, runJourney's resolveMcp
option and a Pack B's mcp.endpointId. A downstream's own diff verification
calls the identity modes directly:
import { diffPacks } from './vendor/observogram/diff.mjs';
import { identityMode } from './vendor/observogram/identity-modes.mjs';
diffPacks(a, b, { identity: 'name' }); // pairs by name; behaviour still decides drift
identityMode('id').keyOf(artefact, { side: 'a' }); // the id key the diff usestools/test-diagnostic-grade.mjs fails CI if an import ever creeps into the
two studio zero-import modules, and tools/test-blast-radius.mjs /
tools/test-chain-history.mjs guard theirs the same way (no import, no
node: module, no process), so the seams cannot erode silently.
Additive keys on compareBranches output. tools/lib/traceability-graph.mjs
itself is not in the vendorable set (it imports the PromQL parser), but its
result is the diff.traceabilityGraph a vendored computeDiagnosticGrade
scores Drift-free on and the input branchRecordsFromGraph reads, so its
shape is part of this contract. Since 2026-09 it keeps its original shape
and gains optional keys a downstream host may ignore: on every node verdict
blastRadius (the blast-radius summary — { slos, alerts, panels, dashboards, routes, remediations, total } — or null) and ladder
({ rung, status, detail }), appended after deltas; on every branch
ladderVerdict, ladderIntegrity and ladderIntegrityPct beside
verdict / integrity / integrityPct; on the rollup ladder = { healthy, degraded, broken, unobserved, integrityMean, integrityPct }, appended last.
integrity, verdict, counts, node status and rollup.integrityMean
are byte-identical with and without the annotations the ladder reads
(pinned in tools/test-traceability-graph.mjs), and Drift-free still reads
rollup.integrityMean — the switch to rollup.ladder.integrityMean is a
proposal (docs/SCORING_PROPOSAL_LADDER_INTEGRITY.md, gradeSchema 3), not
applied. Keys are only ever added here, never renamed or removed.
Additive keys on partialLiveEvidence(packB). The result keeps its
original shape (isLiveDraft, failed, empty, attempted, partial —
partial still means outright probe failures only) and gains optional keys
a downstream host may ignore: unsupported (probe families the MCP tier does
not expose, from mcp.probesUnsupported), errors ({ family: message }
from mcp.probeErrors.<family>), and vantage — 'full' (nothing failed,
nothing unsupported), 'partial' (some probes failed), 'restricted' (nothing
failed, some families not exposed), 'lost' (every attempted family failed or
is unsupported), 'none' (not a live draft). Keys are only ever added here,
never renamed or removed.
Optional inputs computeDiagnosticGrade reads. The diff-bucket path
counts a layer bucket's optional scaffold array (placeholders diffPacks
parks before pairing) into its N scaffold excluded note; older diffs without
it still pass through the isScaffoldDiffEntry filters. It counts the
optional notObserved array the same way (N not checked): artefacts one
pack holds in a family the other could not observe move no weight. The Fresh criterion
appends the live vantage to its detail (vantage lost … / vantage partial …) read through partialLiveEvidence from the same module — the pass/fail
and every score are unchanged, and no import was added.
The studio's look is three files a downstream app copies verbatim, so its screens read as the same product without a theme of its own to maintain:
design-tokens.css— every value, and only values: the palette, the type and the measures as--og-*custom properties on:root, restated for[data-theme="dark"]. No rule styles an element. Names are semantic (--og-accent,--og-second,--og-warn,--og-fail,--og-info, each with-bg;--og-bg,--og-panel,--og-line,--og-text,--og-muted;--og-font,--og-font-code;--og-radius-*,--og-control-*,--og-button-*,--og-stat-*), so a host changes a hue by overriding a token, never by editing a rule.design-tokens.jsoncarries the same values (themes.light/themes.dark, names without the prefix); regenerate it withnode tools/gen-design-tokens.mjs --write.design-kit.css— the pieces, as.og-*classes that read the tokens and nothing else: no global rule, no element selector, no positioning, so it cannot collide with a host's own stylesheet. Put.og-appon the root.design-bridge.css— for the view stylesheets you already vendor:verdict-ui.cssandproto.cssexpect a set of theme properties from the host; this file defines all of them from the tokens. Load it after them.
<link rel="stylesheet" href="design-tokens.css">
<link rel="stylesheet" href="verdict-ui.css"> <!-- and any other vendored view styles -->
<link rel="stylesheet" href="design-kit.css">
<link rel="stylesheet" href="design-bridge.css">Set data-theme="dark" (or "light") on <html>. studio/reskin.css is
not vendorable: it is this studio's adapter, mapping its own chrome names
and legacy classes onto the tokens. A downstream host writes its own adapter
only for markup it already has; new markup uses the .og-* classes.
tools/test-design-system.mjs holds each file to its role in CI: the tokens
file contains nothing but tokens, both themes define every colour, the JSON
equals the CSS, the kit and the bridge read tokens only, the adapter decides
no colour of its own, and no stylesheet reads a token that does not exist.
verdict-ui.mjs reads no global state. Its imports are the vendorable
modules above plus two one-line stubs a host can satisfy trivially:
escapeHtmlfromutil.mjs— any HTML escaper.apifromapi.mjs— only used as the default fetcher ofloadRunHistory(onReady, { fetchFn }); pass your ownfetchFn(path) → Promise<json>and the stub never runs.
The engines take their inputs explicitly:
buildVerdictModel({ pack, packB, diff, compareBId, catalogEntry, passesLens });
projectGrade(uids, { pack, packB, diff, compareBId });pack/packB are adapted layered packs, diff is a /api/diff result,
catalogEntry is whatever row your catalog holds for compareBId (only
.label is read). Observogram binds these from its own state in
proto-synthesis.mjs (verdictInputs()); a downstream studio binds them from
its own store.
View modules never import the app shell. The four app-level callbacks
live in studio/host.mjs (zero-import), filled once at boot:
import { initHost } from './host.mjs';
initHost({ loadPackB, openDeployModal, renderMainView, renderTabs });Views import the live object as import { host as appHost } from './host.mjs'
(see docs/UI_CONVENTIONS.md for the naming rule). A TypeScript host writes a
~10-line adapter object with its own implementations of those four hooks.
(The synthesis view also reuses production renderers from compare-view.mjs
— those are Observogram-specific; port them or swap in your own panels.)
- The state-slice signatures above. New inputs arrive as new optional keys, never as global reads.
- The
initHosthook names (loadPackB,openDeployModal,renderMainView,renderTabs) — the host object never becomes a mirror of app.mjs (docs/UI_CONVENTIONS.md). - Design token and kit names.
--og-*properties and.og-*classes are only ever added, never renamed or removed; token names stay semantic (a role, not a colour word — CI rejects--og-lime).design-tokens.jsonkeepsversion: 1while that holds. Values (a hue, a radius) may change: that is a re-copy, not a migration. - Pack annotation namespace — writers emit
observogram.*(observogram.diff.scopeMode,observogram.retrofeed.*,observogram.services,observogram.unobserved.<kind>); readers keep accepting the pre-rebrandtomograph.*keys. - Diff buckets and entry fields are only ever added.
notObserved(per layer, andsummary.notObserved) is the latest: a host whose live pack comes from its own fetcher gets it by writingobservogram.unobserved.<kind>= reason for every artefact family that fetcher cannot look at (docs/DIFF.md); a host that writes none sees an empty array and the counts it always had. A metric family's entry addsseriesandmemberIdsto the artefact it embeds; a consumer that lists a pack's own artefacts beside the diff usesmemberIdsto give every series its family's status (the studio's Compare does). L4_SUBGROUPSis intentionally duplicated:constants.mjsowns the display copy,diagnostic-grade.mjsinlines a private copy so it stays zero-import. Change both or neither.- Env/header compat for downstream servers proxying Observogram: every
OBSERVOGRAM_*env var also honors itsTOMOGRAPH_*spelling (tools/lib/brand-env.mjs), and the server accepts bothX-Observogram-Org/X-Observogram-CSRFand the legacyX-Tomograph-*headers (echoingX-Observogram-Org).
-
Record the Observogram commit you vendored from (e.g. in a
packages/upstream/UPSTREAM_SHAfile). -
To update:
git -C observogram diff <UPSTREAM_SHA>..HEAD -- \ tools/lib/diff.mjs tools/lib/artefact-model.mjs tools/lib/artefact-classify.mjs \ tools/lib/promql-canon.mjs tools/lib/promql.mjs \ tools/lib/protocols.mjs \ tools/lib/stack-evidence.mjs tools/lib/contracts/stack-self-metrics.mjs \ tools/lib/blast-radius.mjs tools/lib/chain-history.mjs tools/lib/audit-report.mjs \ studio/diagnostic-grade.mjs studio/artifact-model.mjs \ studio/constants.mjs studio/verdict-ui.mjs studio/verdict-ui.css \ studio/compare-catalog.mjs studio/host.mjs studio/proto-synthesis.mjs \ studio/design-tokens.css studio/design-tokens.json \ studio/design-kit.css studio/design-bridge.css studio/discover-board.mjs \ studio/taxonomy.mjs studio/verdict-html.mjs studio/glossary.mjs
diff.mjs→artefact-model.mjs→promql-canon.mjs→promql.mjsis one import chain (artefact-model.mjsalso pullsartefact-classify.mjs): always re-copy these five together. A mixed-generation copy (e.g. a newerdiff.mjsover an older identity model) is exactly the silent drift this contract exists to prevent — a downstream studio's 2026-08 collision report was filed from such a copy, against engine code this repo had replaced on 2026-06-09. Most recent reason to re-copy the pair: 2026-09promql-canon.mjstightens whitespace around symbolic binary operators (a / b≡a/b) andartefact-model.mjsstrips a leadingref:from reference fields before comparing behaviour — an olderartefact-model.mjsover the newer canon (or vice versa) reports drift the other half no longer sees. 2026-10:diff.mjsfolds metrics into families and readsobservogram.unobserved.*through helpers that live inartefact-model.mjs(foldMetricFamilies) — the newerdiff.mjsdoes not load over an older model. -
Re-copy the changed files, re-run your adapter's type-check, bump the recorded sha. Because the modules take their inputs explicitly, upstream changes surface as signature diffs at copy time — not as silent behaviour drift at runtime.