Skip to content

Latest commit

 

History

History
284 lines (250 loc) · 26.6 KB

VENDORING.md

File metadata and controls

284 lines (250 loc) · 26.6 KB

Vendoring Observogram modules downstream

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.

The manifest

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.

The vendorable set

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 uses

tools/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 design system

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:

  1. 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.json carries the same values (themes.light / themes.dark, names without the prefix); regenerate it with node tools/gen-design-tokens.mjs --write.
  2. 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-app on the root.
  3. design-bridge.css — for the view stylesheets you already vendor: verdict-ui.css and proto.css expect 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.

What the host supplies

verdict-ui.mjs reads no global state. Its imports are the vendorable modules above plus two one-line stubs a host can satisfy trivially:

  • escapeHtml from util.mjs — any HTML escaper.
  • api from api.mjs — only used as the default fetcher of loadRunHistory(onReady, { fetchFn }); pass your own fetchFn(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.)

Contracts this repo keeps stable

  • The state-slice signatures above. New inputs arrive as new optional keys, never as global reads.
  • The initHost hook 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.json keeps version: 1 while 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-rebrand tomograph.* keys.
  • Diff buckets and entry fields are only ever added. notObserved (per layer, and summary.notObserved) is the latest: a host whose live pack comes from its own fetcher gets it by writing observogram.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 adds series and memberIds to the artefact it embeds; a consumer that lists a pack's own artefacts beside the diff uses memberIds to give every series its family's status (the studio's Compare does).
  • L4_SUBGROUPS is intentionally duplicated: constants.mjs owns the display copy, diagnostic-grade.mjs inlines 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 its TOMOGRAPH_* spelling (tools/lib/brand-env.mjs), and the server accepts both X-Observogram-Org/X-Observogram-CSRF and the legacy X-Tomograph-* headers (echoing X-Observogram-Org).

Staying current

  1. Record the Observogram commit you vendored from (e.g. in a packages/upstream/UPSTREAM_SHA file).

  2. 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.mjs is one import chain (artefact-model.mjs also pulls artefact-classify.mjs): always re-copy these five together. A mixed-generation copy (e.g. a newer diff.mjs over 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-09 promql-canon.mjs tightens whitespace around symbolic binary operators (a / b ≡ a/b) and artefact-model.mjs strips a leading ref: from reference fields before comparing behaviour — an older artefact-model.mjs over the newer canon (or vice versa) reports drift the other half no longer sees. 2026-10: diff.mjs folds metrics into families and reads observogram.unobserved.* through helpers that live in artefact-model.mjs (foldMetricFamilies) — the newer diff.mjs does not load over an older model.

  3. 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.