Skip to content

0451 — Public set-piece data is a sanitised build-time snapshot, and every published field is screened by the same gate that governs the pages

The public showcase at arronpitman.com carries four interactive panels built from estate data: a service-dependency graph, a decision-log explorer over every ADR in the corpus, a DORA delivery-metrics panel, and an agent-operations visualisation. This ADR records how that data is obtained, what is removed from it before publication, and the three specific traps that shaped the answer. Epic #2557, Issue #2560.

Accepted — 2026-08-20. Design: 2557-arronpitman-public-showcase. Extends ADR-0450, which governs which pages may ship; this one governs the data the site publishes alongside them.

ADR-0450 settled publication for corpus pages: public: true is an opt-in intent control, and public_scrub_gate.py is a fail-closed content backstop that reds and never rewrites. That pair is sound and is not revisited here.

It does not cover the set-pieces, and the gap is not cosmetic. The panels publish three things the flag was never asked about:

  1. Derived estate data. The internal servicemap graph (ADR-0359) names every service and host and links each node to its dashboard, its documentation page and its external hostname. It is the right artifact for an operator and an enumeration of the estate’s attack surface for anyone else.
  2. Metadata for pages that are NOT published. The decision explorer lists every ADR in the corpus, not just the 45 that ship in full — because a decision log with the awkward entries filtered out is a marketing document. Titles and tags are corpus content; the flag governs pages.
  3. Numbers about delivery. DORA figures, which are trivially flattering to compute badly and for which a placeholder is worse than an absence.

Three decisions follow, and each was forced by something measured rather than anticipated.

1. The topology is published with role pseudonyms, and the sanitiser asserts its own output

Section titled “1. The topology is published with role pseudonyms, and the sanitiser asserts its own output”

Labels become role words derived from each node’s own kind and domain“media service 3”, “infra host 1” — numbered stably so a rebuild does not churn the data. external_url, docs, dashboard and blurb are dropped entirely; a service’s host is replaced by that host node’s pseudonym. The mapping is derived from the node’s own attributes rather than from a hand-written list of estate services, because a hand list is a population claim (FC-19) that silently misses every service added after it was written.

What survives is the part a visiting architect actually wants: how many nodes there are, how they cluster by domain, which kinds of relationship exist, and that four independent derivations agree about all of it. The visualisation is about shape and derivation, not names.

The sanitiser then asserts its own output and aborts the build on any surviving estate identifier, internal domain or private address — and the assertion is proven to fire against a deliberately poisoned graph by --selftest before it is trusted, per ADR-0141. A sanitiser exercised only on clean input has never been observed to work.

2. Published metadata is screened by the SAME denylist, imported rather than restated

Section titled “2. Published metadata is screened by the SAME denylist, imported rather than restated”

build_snapshots.py imports scan_text() and the inventory host list from public_scrub_gate.py and runs them over every decision title and tag before publishing. A title that trips the denylist is replaced with a visible redaction — “Title withheld — it contains an internal identifier” — and the record stays in the log with its number, status and date. A tag that trips it is dropped.

This was found by the gate, not by design. The first built bundle carried a hostname from the estate’s internal DNS zone inside an ADR title, and the dist/ scrub pass reddened on it. The intent control could not have caught it: that ADR is not flagged, so no human ever made a publication decision about it, and the panel published its title anyway.

(This page is itself flagged public: true, so the gate reddened on the first draft of the paragraph above for naming the offending host. That is the control working on its own documentation, and the fix was the one the gate always demands: sanitise the page, never weaken the gate.)

The denylist is imported, never copied. A second copy of a security rule is a copy that drifts, and the drift is silent (FC-13). If the gate cannot be imported the build fails rather than publishing unscreened metadata: “I could not look” and “I looked and it was fine” must never share an outcome.

3. DORA is a read-only report that imports the estate’s classification — it is not a second collector

Section titled “3. DORA is a read-only report that imports the estate’s classification — it is not a second collector”

ADR-0331 states the rule plainly: do not build a second collector against the builds API, because two pollers drift apart and the disagreement is discovered during an incident. The snapshot step honours it in the way that matters — it writes no series, no counters and no state, and it imports DEPLOY_PREFIXES, DEPLOY_EXCLUDE_SUBSTR and CI_REASONS from ado_pipeline_metrics.py rather than restating them. One declaration, two consumers; a change to the estate’s deploy classification moves the public panel with it.

Each of the four keys carries its derivation on the panel, and the change-failure-rate tile says explicitly that it measures changes that failed to reach production, which is not DORA’s “caused a degradation in service”. Borrowing the more flattering definition is the cheapest way to publish a false number. The Elite/High/Medium/Low bands are labelled as the DORA report’s own thresholds, not as anything measured about this estate (FC-16).

Two mechanical properties, both learned the expensive way elsewhere:

  • History is walked in bounded time slices, and each slice asserts it came back under the API’s result cap. A single unpaged call returns 2,000 runs, which is eleven days of this estate’s volume — it would silently measure a third of a 28-day window and report it as the whole thing (FC-21).
  • A run missing a timestamp is skipped, never defaulted to zero, and failures still unrecovered at the window’s end are counted separately rather than folded into the recovery median as zero.

4. Every source fails closed, and every panel states its own age

Section titled “4. Every source fails closed, and every panel states its own age”

A source that is unavailable fails the build. The site then keeps serving its previous deployment, whose panels each display the moment their data was taken. That is the correct failure: an empty chart and a working one are indistinguishable to a reader, and a placeholder value on a public engineering site is a fabrication.

Nothing is live and nothing claims to be. Every panel carries a visible, server-rendered timestamp — not injected by script, so it is present with JavaScript disabled and an assertion on it can fail when it is absent.

5. Set-piece data joins the publish manifest by content digest, not by file hash

Section titled “5. Set-piece data joins the publish manifest by content digest, not by file hash”

The site pipeline skips build and deploy when what it would publish is byte-identical to what the live site serves. The snapshots must participate, or a rebuild whose only change is a new decision record compares equal and is skipped — the panels freeze while every other signal reads healthy. But hashing the files themselves would differ on every run, because each carries its own snapshot_at, and the skip branch would become unreachable code that always looks correct (FC-17). So each artifact contributes a digest taken with its volatile fields removed.

6. Flagging decision records extends the cross-repo trigger in the same commit

Section titled “6. Flagging decision records extends the cross-repo trigger in the same commit”

45 ADRs were flagged for the depth links the authored chapters point into. The scrub gate reds a flagged page sitting outside the site pipeline’s pitlab-docs trigger paths — such a page publishes once and then silently never updates — so docs/adr/** was added to those paths in the same change. The coupling is enforced rather than remembered, and the gate reads the trigger list out of the site pipeline itself so there is no second copy to drift.

Push order is load-bearing and is recorded here because it is not obvious: the docs pipeline reads the site pipeline from arronpitman-site origin/main, so the site repo’s trigger change must land first. Pushing the flags first reds pipeline 18, which is batch: true and shared — it blocks every concurrent session’s docs.

  • Publish the topology with real service names. Rejected. The design’s premise is that information disclosure is the one genuine risk this site carries, and the scrub gate does not block *.pitbun.com hostnames, so the judgement is not delegated to a control — it is mine. The asymmetry decides it: an anonymised graph is merely less flashy, a published enumeration has no rollback.
  • Drop the topology set-piece rather than pseudonymise it. Rejected. The interesting claim — that the graph is derived from four independent sources and fails closed rather than publishing a half-empty map — survives pseudonymisation completely.
  • Sanitise the offending ADR titles in the corpus instead of withholding them. Rejected as out of scope and wrong in principle: editing an internal document’s factual content for an external reason degrades the internal record. The publication surface adapts to the corpus, not the reverse.
  • Exclude records with unpublishable titles from the log. Rejected — a silently shorter list is exactly the failure the visible redaction avoids.
  • Restate the deploy classification locally rather than importing it. Rejected on ADR-0331’s own reasoning; a public figure disagreeing with the internal SLO about what a deployment is would be wrong in a way nobody would notice.
  • Query the estate’s Prometheus for the DORA series instead of the builds API. Rejected on measurement: the existing exposition carries lead time and per-pipeline run counts but not deployment frequency or failed-deployment recovery time, and Prometheus is not readable from a build agent without provisioning a credential.
  • Render the decision explorer’s rows client-side from JSON. Rejected. The full log would not be in the document, so the acceptance assertion would be satisfiable by a script that merely ran (FC-02), and the page would be empty without JavaScript. The cost is about fifteen Lighthouse performance points on that one page, which is stated on the gate rather than hidden by leaving the page out of the measured set.
  • A scheduled job that pushes sanitised data into the repo. Rejected in the design: it adds a job, a watchdog and a sanitiser running outside the build’s own gates.
  • The published graph cannot be used to enumerate estate services or hosts, and the build proves that on every run rather than relying on the last person to have checked.
  • The decision log is complete — every record in the corpus is listed — while remaining publishable, and the gaps it exposes (71 records with no stated status, 93 dated from their first commit, one title withheld) are visible rather than smoothed over. That is the intended behaviour: the panel’s value is that it does not curate.
  • The public DORA figures and the internal CI queue-time SLO cannot come to disagree about which pipelines count as deployments.
  • Panels go stale rather than wrong. A failed source leaves the previous deployment serving with an honest timestamp; the freshness watchdog and the weekly rebuild belong to Issue #2561.
  • Flagging decision records means the site pipeline now triggers on any docs/adr/** push. The manifest short-circuit absorbs the ones that change nothing publishable.
  • A PARTIAL CENSUS REFUSES RATHER THAN PUBLISHES (added 2026-08-21, Issue #2594). The four set-pieces added by Issue #2594 extended this rule from “a missing source aborts the build” to “an INCOMPLETE source aborts it too”, and the extension was written because the gap was walked into. The CI gate map derived its population through the ADO Git API using SYSTEM_ACCESSTOKEN, whose job authorization scope is limited to the repositories its own pipeline references — so in CI it could read three of the hundred pipeline definitions. Run 10325 published a 30-script census under a 100-pipeline headline, with definitions_unreadable: 97 recorded in the artifact and rendered nowhere a reader looks. The page’s DOM matched its JSON exactly, so the acceptance gate’s equality assertion was satisfied by two consistent wrong numbers; only its absolute floor caught it. The lesson generalises past this panel: for a snapshot that makes a population claim, “the source answered” is not the same as “the source answered fully”, and a smaller number carrying the same confidence is worse than no number at all.
  • Files are read from the build agent’s own checkouts, with the API as fallback (2026-08-21). Every CI pool agent carries all twelve repositories the project builds from (ci_agent_repos, ADR-0337) — verified against all 100 pipeline YAML paths on runner01 and runner02, because the pool is plural (ADR-0333). The earlier API-only design rested on a repository count that had been recalled rather than counted. reads_from_checkout and reads_from_api are published so which route answered is visible rather than inferred.
  • The publish scrub gate is proven to READ the snapshot data, not merely to hold its rules (2026-08-21). The gate’s own --selftest proves its patterns fire; it says nothing about whether the gate ever scans dist/data/*.json, and a gate wired to a tree it cannot see is silent for the same reason a clean tree is silent. prove_setpiece_scrub.sh poisons the real built bundle once per panel, each fixture tripping a different rule, and requires a refusal that NAMES the file — behind a positive control, because “it refused” is otherwise equally explained by a gate that refuses everything. Measured cost: 53 seconds per publishing run, paid only when the manifest short-circuit does not skip.
  • Residual, stated not fixed: the denylist blocks the internal DNS zone and any mailbox at the estate’s mail domain, but not the estate’s public hostnames — so an ADR title naming one is published. Those names already resolve publicly and appear in certificate transparency logs, so the marginal disclosure is small — but whether public hostnames belong in published metadata is a standards question, not a per-instance one, and it is raised as a snag for adjudication rather than answered here by widening a security rule mid-delivery.