Design-challenges register (preemptive resolutions)
Status: living register, companion to ADR-039 and the hub design. Each entry is a challenge that would otherwise surface mid-implementation, with a resolution designed now and an owning sprint/task. Executing agents treat a DC as binding design input; deviating requires a recorded decision.
| # | Challenge | Owner |
|---|---|---|
| DC-01 | Canonical product identity + applicability model | 004 VULN-C2 |
| DC-02 | Applicability-hash canonicalization | 004 VULN-C2 |
| DC-03 | CSAF product-tree fan-out control | 004 VULN-C2 |
| DC-04 | Vulnerability alias graph + issue re-keying | 005 VULN-D1 |
| DC-05 | Hash keys as bytea(32), hex only at edges | 003 VULN-B3 |
| DC-06 | Outbox event envelope + versioning contract | 003 VULN-B4 |
| DC-07 | Event transport + inbox dedup | 005 VULN-D1 / 007 POL-F1 |
| DC-08 | Linkset rebuild storms (backpressure) | 005 VULN-D1 |
| DC-09 | Corpus-readiness contract (degraded gates) | 007/008 |
| DC-10 | Tenant-local VEX placement + read composition | 007 POL-F1 / design §3.2 |
| DC-11 | Decision-time independence invariant (→ ADR P15) | ADR-039 |
| DC-12 | Shared-catalog replacement mechanics (X14: tenants + actor_identity) | 027 (library + producers) + 016 (tenants side) |
| DC-13 | Gate-evaluation submission exactly-once (X8) | 012 JOB-1 |
| DC-14 | Estate-doctor privilege isolation | 009 DOC-4b / 012 |
| DC-15 | Scanner read-contract replacement (X11–X13) | 017 SCN-1 + 010/024/007 |
| DC-16 | Evidence immutability through the merge | 011 EVD-9 |
| DC-17 | Conformance-test mechanics (implementable) | 003 VULN-B5 |
| DC-18 | OfflineKit unified bundle envelope | 022 EVL-3 (pre-design) |
| DC-19 | Gateway route-change protocol across programs | recipe S5 / 006 |
| DC-20 | Connection/env-var management convention | recipe S8/M3 |
| DC-21 | Envelope = evolve AirGap.Bundle, never a parallel format | 025 OK-1 |
| DC-22 | ExportCenter runs = the single bundle-production engine | 025 OK-4 |
| DC-23 | Sealed-mode enforcement-point map (all converge on Platform posture API) | 025 OK-1/OK-5 |
| DC-24 | VEX-side applicability extraction + absent/fixed-only conventions | 004 VULN-C2 |
| DC-25 | Rename blast radius → stable contract assemblies | 003 VULN-B1 |
| DC-26 | Dismantle Platform.Database’s central migrator (24 plugins / 23 schemas) | 026 (mechanism + runtime faces) + recipe S3/M1 (per-program deletions) + 021 PLT-4 (final sweep) |
| DC-27 | Valkey keyspace/stream namespace ownership | 028 NPS-1 + per-program S8/M3 |
| DC-28 | Object-store bucket/prefix ownership + budgets | 028 NPS-2/3 + matrix §6 |
| DC-29 | One outbox lineage: evolve StellaOps.Eventing (kills X17) | 003 VULN-B4 |
| DC-30 | One corpus artifact for every matcher (supersedes the projection idea) | 005 (producer) + 007/024/CLI (consumers) |
| DC-31 | Gate explanations: derive, don’t store (deterministic re-derivation) | 007 POL-F1 |
| DC-32 | Overlay architecture: Findings ledger = system-of-record for tenant VEX decisions; Policy consumes events | 010 + 007 |
| DC-33 | BinaryIndex knowledge plane dissolves INTO the hub (binary-granularity facts) | 014 (re-scoped) + 004/005 |
| DC-34 | SbomService = the SBOM system of record (single owner of four scattered homes) | 020 W3-07 (re-scoped) + 010/017 |
| DC-35 | One lease lineage: the P6 lease (with fencing) absorbs/aligns the estate’s three unfenced lock tables | 003 VULN-B4 + 012/015/020 |
| DC-36 | Replayability = derive-on-demand over raw docs + revision history (no artifact archival) | 005/007 + 025 (bundle history) + Replay |
| DC-37 | Estate restore doctrine: restore classes + epoch-driven convergence + drills | recipe + runbooks + 021 audit |
| DC-38 | One consensus-evaluator library; sectioned corpus artifact feeds it | 005 VULN-D4 + 007 POL-F1 |
| DC-39 | Contract-version discipline at the artifact + plugin seams | 005 VULN-D4 + 003 B1 + 009 DOC-1 |
DC-01 — Canonical product identity + applicability model
Challenge. Fact identity includes product_key and an applicability hash, but sources speak different product languages: OSV/GHSA-style purl + version events, distro advisories in EVR/NEVRA/apk world, CSAF product trees, vendor free-text. Without one canonical model, “disjoint ranges are distinct facts” is unimplementable and dedup silently fails across sources.
Resolution. Two-layer product model:
product_key= canonical purl without version (type/namespace/name normalized per purl spec; distro packages aspkg:rpm/...,pkg:deb/...with distro qualifier; non-purl-able products getpkg:generic/<issuer-namespaced>with the issuer’s stable product id as name). The currentvex.product_dedup/product_key_aliaseslogic is the carry-over seed.applicability= normalized structure{scheme, events[] | ranges[]}whereschemenames the comparator (semver,evr,deb,apk,pep440,maven,generic-exact) and ranges are half-open[introduced, fixed)events sorted ascending. One fact = one contiguous range; a statement covering N disjoint ranges becomes N facts (per D4) sharingsource_statement_idwith anapplicability_indexprovenance attribute. Comparator implementations are the ones the estate already trusts — the sharedsrc/__Libraries/StellaOps.VersionComparisonlibrary plus Concelier’sMerge/Comparers(Nevra,ApkVersionComparer); the hub consolidates them behind theschemeregistry, never re-invents. Carry-over evidence (dig 2026-07-22):StellaOps.Concelier.Models.AffectedVersionRange+ToNormalizedVersionRule()already realize this structure on the advisory side, with 100% of 1,709,145 liveadvisory_affectedrows carrying ranges — the advisory work is a verification pass. The VEX side is the gap: see DC-24.
DC-02 — Applicability-hash canonicalization
Challenge. applicability_hash participates in fact identity; two encoders producing >=1.0 <2.0 vs [1.0,2.0) must not mint two facts.
Resolution. Hash = SHA-256 over an RFC 8785 (JCS) canonical JSON of the normalized structure from DC-01 (scheme lowercased, events sorted, versions in source-exact form — no version “normalization” beyond trimming, because rewriting versions corrupts comparator semantics). The canonicalizer is one shared function with a frozen fixture corpus (same range expressed five ways → one hash) — CODE_OF_CONDUCT §7.2 applies.
DC-03 — CSAF product-tree fan-out control
Challenge. One CSAF statement can reference a product tree expanding to thousands of product ids; naive normalization mints thousands of facts per statement and re-creates the old explosion inside the new model.
Resolution. Fan-out is bounded and observable: normalization emits facts per canonical product (post product-dedup, not per raw tree leaf); per-document fan-out metric with a P7 budget (document quarantined, not dropped, above budget); provenance keeps the raw tree path so drill-down never loses the original reference. Fixture: a RedHat CSAF with a large tree → measured fan-out asserted.
DC-04 — Vulnerability alias graph + issue re-keying
Challenge. Issue identity uses a canonical vulnerability id, but aliases (CVE↔GHSA↔distro ids) arrive incrementally and can merge/split. If alias resolution is baked into fact identity, a late alias flips identities and orphans provenance.
Resolution. Facts keep the issuer-stated vulnerability id (identity-stable forever) plus an alias set as data. Canonical resolution lives one layer up: the consensus builder maintains an alias graph (facts.vuln_aliases, union-find with source-ranked tie-breaks: CVE wins as canonical when present) and linksets key on the resolved id. An alias-graph change emits a bounded re-link event (only affected issues rebuild — DC-08 path), never touches fact rows. Merge/split history is append-audit.
DC-05 — Hash keys as bytea(32)
Challenge. fact_id/hashes as hex text (71 B) across PKs, FKs, and five indexes costs ~40% more index bytes than needed at 5–10 M rows — the old plane’s index bloat pattern re-entering through the front door.
Resolution. All content hashes in stellaops_vuln are bytea (32 bytes) at rest; APIs and logs render sha256:<hex>. One conversion helper in the persistence layer; index-size budget in the P13 header verified against this choice.
DC-06 — Outbox event envelope + versioning contract
Challenge. Hub→Policy events are a decades contract; unversioned payloads force lockstep deploys forever.
Resolution. Envelope: {event_id (ulid), stream, seq, type, v, occurred_at, payload} with type ∈ {fact.changed, fact.tombstoned, linkset.changed, generation.opened, generation.completed} and v an integer per type. Consumer rules: unknown type → skip + count; unknown field → ignore; v bump only on breaking change, consumers support v and v-1 during a deprecation window. Payloads carry ids + hashes, never full documents (consumers fetch detail via API if needed) — keeps the outbox narrow and replayable.
Amended (round 22, owner-approved R2): the envelope gains stream_epoch— a producer-instance generation, bumped on any rebuild/restore of the producer’s database (fresh DB ⇒ new epoch by construction: it is stored in the producer’s own DB alongside the outbox). Consumer rule: an epoch change ⇒ discard the seq checkpoint, run a full re-sync via the DC-12 pull-then-subscribe machinery, then resume seq-based consumption under the new epoch. Without this, any producer restore restarts seq below consumer checkpoints and every consumer silently skips all future events forever (round-20 R2 — the T12 hub-rebuild scenario would otherwise end in permanent, alarm-less Policy staleness).
DC-07 — Event transport + inbox dedup
Challenge. The estate has two historically-unbridged pipes (eventing vs messaging); picking wrong (or both) re-creates the drift. Cross-cluster polling vs push must also survive offline estates.
Resolution. Transport = the existing router/Valkey messaging (in-stack, works air-gapped): outbox publisher per stream publishes envelopes; the outbox table remains the source of truth — consumers that miss messages catch up by pulling seq > checkpoint via the hub’s outbox API. Inbox dedup on (stream, epoch, seq) unique constraint (round-22: epoch joins the key); processing is idempotent by design (P6), so at-least-once delivery is safe. No second eventing path: one publisher, one contract. Round-22 additions: the seq catch-up API is part of the Eventing lineage for EVERY producer (not hub-only), and each producer family ships the DC-37 chaos test — restore/rebuild the producer ⇒ consumers detect the epoch change and converge.
DC-08 — Linkset rebuild storms
Challenge. An alias-graph change, product-dedup improvement, or consensus-rule tweak can dirty millions of linksets at once; unbounded rebuild starves ingest and floods WAL (the old plane’s sweep pathology, reborn).
Resolution. Single dirty-queue with (issue_key, reason, enqueued_seq); the projector drains with a rate budget (rows/sec + WAL/batch from P7) and strict priority: correctness events (tombstones) > ingest-driven changes > mass re-links. Mass operations enqueue with a campaign_id and progress accounting so operators see “re-link 41% complete” instead of a mystery load. Rebuilds are content-guarded (no-op writes stay zero).
DC-09 — Corpus-readiness contract
Challenge. After the B7 window, gates run against a filling corpus. “Degraded” must be a machine-readable contract, not a vibe — otherwise gates either lie (pass on empty data) or block releases for days.
Resolution. The hub exposes GET /api/vulnerabilities/v1/readiness: per-source {source, last_completed_generation, completed_at} + an aggregate ready flag (all Tier-0 sources ≥1 completed generation). The Policy projector stamps every verdict derived while ready=false with corpus_state: rebuilding; release gates surface it per the recorded degraded-mode decision (sprint 007 open decision: fail-open-with-warning vs fail-closed). Console badges read the same endpoint. Readiness is generation-based — never wall-clock.
DC-10 — Tenant-local VEX placement + read composition
Challenge. The hub is tenant-free (P12), but customers author their own VEX statements. Where do they live, and how does the UI show global + local as one view?
Resolution. Tenant-authored VEX = a control-plane overlay fact in policy.vuln_tenant_overlay (kind local_vex), same D4 identity discipline (issuer = the tenant), applied by the Policy projector at gate derivation. Carry-over (dig 2026-07-23): the overlay backbone already exists — Policy’s schema holds a full exceptions machinery (exceptions, exception_applications, exception_approval_requests/rules/audit, exception_events, plus risk_overrides). The overlay design evolves these tables (DC-21 lesson), it does not add a parallel overlay store; local_vex joins as a new overlay kind alongside the existing exception kinds.
Widened (round 12): tenant VEX/override data currently lives in FOUR homes — (1) the policy.exceptions machinery above; (2) findings.vex_decisions + vex_override_envelopes + vex_statement_trust_override; (3) analytics.vex_overrides; (4) the hub-bound tenant local-VEX. The 007/010 overlay consolidation maps ALL of them onto the one overlay model (trust overrides relate to the consensus trust-cap layer); the four-home census is 010 S0’s checklist, and no fifth home may appear (conformance grep). Read composition happens in Policy’s API (the only tenant-aware reader): Console’s issue drill-down calls hub for global facts/consensus and Policy for the tenant’s overlays + effective verdict; the hub never sees a tenant id. Verify-item for 004 S0: the current Excititor local-VEX ingestion path (if any) redirects to the overlay API.
DC-11 — Decision-time independence invariant (→ ADR-039 P15)
Challenge. With vuln data on its own cluster, a vulndata outage must not stop releases — but nothing currently forbids a gate-time synchronous call into the hub.
Resolution (policy). New ADR invariant P15: release decisions read only control-plane state (Policy’s own projections). The hub is upstream-async, never in the decision path. Enforced by conformance (Policy holds no vulndata-cluster connection — its projector consumes events) and by a chaos test: stop the vulndata cluster → gates keep deciding on last-applied state with a staleness annotation.
DC-12 — Tenant-resolver replacement mechanics (X14)
Challenge. StellaOpsTenantResolver (shared library) SQL-reads shared.tenants from every host’s connection. Post-separation each host’s DB has no such table, and an Authority-API-per-request design would make every service’s hot path depend on Authority liveness.
Resolution. Event-carried replica: Authority outbox publishes tenant catalog changes (DC-06 envelope); the shared library gains a tenants_replica table instantiated in each host’s own database (P4 pattern, like crypto) + an inbox consumer + bootstrap pull-then-subscribe on empty replica. Scope widened (dig 2026-07-23): shared holds a second cross-service catalog — shared.actor_identity (Platform-produced) — the same replica mechanism serves both catalogs with two producers (Authority → tenants, Platform → actor identities); 016 designs the mechanism once, consumers adopt both. The resolver reads the local replica — same latency/offline behavior as today, zero cross-DB. Rollout: library ships dual-mode (legacy read OR replica) behind the host’s wave flag; 016 flips Authority’s write side; each service adopts with its own program; PLT-4 verifies no legacy reads remain.
DC-13 — Gate-evaluation submission exactly-once (X8)
Challenge. Policy’s gate evaluations currently ride direct scheduler-table writes (transactional with Policy’s own state). A naive HTTP submission API loses that atomicity — a crash between “decide to evaluate” and “submitted” silently drops a gate check.
Resolution. Policy keeps a tiny local submission outbox (its own DB): decision txn writes the intent; a dispatcher submits to JobEngine’s API with an idempotency key (sha256(tenant|subject|input_hash)); JobEngine dedups on the key (unique index) and returns the existing job on replay. Status flows back via DC-06/07 events (or polling fallback). Exactly-once effect from at-least-once submission + dedup — same pattern as the hub projector. Wiring, not invention (dig 2026-07-23): Scheduler already exposes a job-creation endpoint family (MapPost("/jobs") in VulnerabilityResolverJobs/ResolverJobEndpointExtensions.cs:22) — DC-13 extends that pattern with the idempotency-key contract rather than building a new surface.
DC-14 — Estate-doctor privilege isolation
Challenge. Estate-level doctor packs (Docker checks) need the docker socket — handing it to the general JobEngine worker hands root-equivalent power to every pack.
Resolution. A dedicated jobengine-estate-worker role container: mounts the socket read-only where possible, subscribes only to the estate-doctor queue, executes only packs signed with the ops-pack signing role (ADR-030 chain), with an explicit capability allowlist in the pack manifest (checks may inspect; ADR-026-gated remediations require the approval token in the run request). The general worker never mounts the socket. Compose/security review is part of 012’s S8. Mechanics grounded (dig 2026-07-22): packs.pack_versions already carries manifest_json + manifest_digest, signature fields (signed_by, signature_algorithm, signature_uri), lifecycle status, and min_engine_version — the capability allowlist is a manifest_json field validated at publish (registry rejects undeclared capabilities for the signing role) and enforced at run (the worker intersects pack capabilities with its role’s allowed set); the estate-doctor queue restriction maps onto the existing scheduler job-kind catalog/plugin descriptors.
DC-15 — Scanner read-contract replacement (X11–X13)
Challenge. Three services JOIN scanner.* today; after separation those queries break at grant revocation, and each consumer needs different data shapes (Findings needs digest-scoped inventory, Integrations needs severity counts per registry image, Policy needs match inputs).
Resolution. One producer contract, two consumption styles: Scanner publishes scan.completed {digest, image_ref, sbom_id, component_stats, severity_counts, …} events (DC-06 envelope) for event-carried projections (Findings.Security and Integrations each keep the narrow columns they render, in their own DB), plus a digest-scoped read API for on-demand detail (Policy’s per-evaluation needs). Each consumer’s program (010/024/007) lands its side before 017’s revocation gate — 017’s SCN-1 tracks the three sign-offs.
DC-16 — Evidence immutability through the merge
Challenge. Evidence stores are hash-chained/WORM-flavored; a botched relational move that rewrites rows breaks auditability silently — parity row-counts would still pass.
Resolution. EVD-9 adds a chain-verification gate: before cutover, run full proof-chain + capsule-digest verification against the new database (content stores are untouched by design — the move is relational only); byte-compare a sampled set of sealed payloads pre/post (same discipline as 019’s sealed-material rule). Any mismatch aborts the window.
DC-17 — Conformance-test mechanics
Challenge. “Conformance test pins ownership” is easy to say; an unimplementable test rots into a skipped test.
Resolution. Three concrete layers in DatabaseOwnershipConformanceTests:
- Source scan (like the existing startup-migrations walker): per service, assert its config binding resolves exactly its own
STELLAOPS_POSTGRES_<X>_CONNECTIONkey and thatSTELLAOPS_POSTGRES_CONNECTIONappears nowhere outside the pinned register file (a checked-in JSON list with owning-sprint annotations — shrinking it is program acceptance). - Compose lint: parse the compose YAML; every service’s connection env must reference its own database name; unknown DB names fail.
- Boot matrix (integration, per service as its wave lands): start the service with only its own connection env → healthy; with the foreign generic env only → must fail closed. Builds on the existing
Infrastructure.Postgres/Testing/PostgresFixture.csharness (dig 2026-07-23) — extended for per-service databases, not reinvented.
DC-18 — OfflineKit unified bundle envelope (pre-design; EVL-3 pending)
Challenge. Today three bundle dialects exist (mirror seed, offline kit, export archives). If OfflineKit is approved but each producer keeps its dialect, the merge is cosmetic.
Resolution (pre-designed for EVL-3). One envelope: DSSE-signed manifest {bundle_id, created_at, producer, parts[]} where each part is {domain, media_type, digest, size, generation}; parts are content-addressed blobs; import = verify envelope → posture gate (Platform seal state) → dispatch parts to domain importers by media_type (hub takes vuln parts, Evidence takes attestation parts, …). Producers become plugins emitting parts, not formats. Existing dialects get one-release import adapters.
DC-19 — Gateway route-change protocol
Challenge. Six programs edit the same gateway route config + OpenAPI aggregation; concurrent edits are a standing merge-conflict and outage source.
Resolution. Routing changes ship as one change per program execution window, authored against the route-config test suite, with the aggregation regenerated in the same commit; the board (002 EST-4) serializes windows. No program edits routes outside its window. Route-config tests are the merge gate (already the repo pattern: *RouteConfigTests).
DC-20 — Connection/env-var convention
Challenge. ~20 new STELLAOPS_POSTGRES_*_CONNECTION vars will accrete ad hoc into .env, each hand-written with embedded credentials — drift and leak surface.
Resolution. One generated block: devops/compose/env/databases.env (gitignored, generated by a small script from the matrix: db name + role + per-service password) sourced by compose; .env.example documents the shape without secrets; roles/passwords provisioned by the same cluster-init script that creates databases. Rule: a connection var appears in exactly one place; compose services reference, never re-declare. Evolution points (dig 2026-07-23): the compose tree already has devops/compose/env/ and devops/compose/postgres-init/00-v1-baseline.sql (+ _archived/) — the generated block and the role/database bootstrap extend these existing homes (first-run fallback only, per §2.7 — never the migration authority).
DC-21 — Envelope authority: evolve AirGap.Bundle
Challenge. DC-18 sketched a unified envelope before the dig; the dig found AirGap.Bundle is already a substantial format library (Extractors, Schemas, Serialization, TrustSnapshot, Validation). Building DC-18 as new code would create the fourth dialect.
Resolution. AirGap.Bundle is the envelope implementation: DC-18’s additions (part-level domain/media_type dispatch, DSSE manifest shape) land as its evolution inside OfflineKit; the legacy dialects (mirror seed, offline kit, export archives) get import adapters for one release. Review gate in 025 OK-1: a forked format fails the program’s purpose.
DC-22 — One bundle-production engine
Challenge. Bundle production paths today: ExportCenter runs, Mirror.Creator shell scripts (make-thin-v1.sh + schedule-export-center-run.sh), and ad-hoc CLI exports — three schedulers for one job class.
Resolution. ExportCenter’s run engine (profiles → runs → inputs → distributions → attestations → verification runs, all already modeled in its 16 tables) becomes the only production path: mirror bundles are a profile kind; Mirror.Creator’s logic becomes a profile executor; CLI commands submit runs instead of building bundles locally (local build stays only for air-gapped one-shot tooling, emitting the same envelope via the same library).
DC-23 — Sealed-mode enforcement-point map
Challenge. Sealing only works if every egress/ingress point checks it; today the checks are scattered (importer policy, Concelier EPSS AirgapMode, per-service config) and Platform’s client binding for the controller URL couldn’t even be located by literal grep — enforcement is implicit.
Resolution. 025 OK-1 produces the authoritative enforcement-point inventory; all points converge on Platform’s posture API. Known second store (dig 2026-07-23): policy.sealed_mode_states — Policy keeps its own sealed-mode table; it becomes a consumer of Platform’s posture (event-fed local cache at most) or dies; the enforcement map must disposition it explicitly (single check, cacheable with a staleness bound consistent with the sealed-mode drift budget); the conformance suite gains a pin: no service holds its own sealed-state store or bypasses the posture API. A seal → import blocked forcing test ships with OK-5 and runs in every OfflineKit window thereafter.
DC-24 — VEX-side applicability extraction + absent/fixed-only conventions
Challenge. The dig proved the current VEX pipeline discards applicability entirely: vex.claims has no range columns and vexhub.statements.versions is null almost everywhere — which is precisely why the old retention identity (provider, vuln, product latest-wins) was destructive. If the hub’s normalization doesn’t extract ranges from VEX documents, D4’s “disjoint ranges are distinct facts” is vacuously true for the largest fact class and the original failure mode survives in disguise.
Resolution.
- Extraction is first-class in 004 VULN-C2: CSAF
product_version_range/version-branch structures and OpenVEX version events normalize into the DC-01 structure; per-format fixtures (RedHat/SUSE/Ubuntu/Oracle CSAF, OpenVEX) assert extracted ranges, not just statuses. - Absent applicability is honest, not invented: a statement with no version information is a product-level fact —
applicability = {scheme: "product-level"}, hashed as such (a distinct, stable identity class). Never synthesize[0, ∞). - Fixed-only sources (common in distro data): represent as
{introduced: null, fixed: X}with the null preserved (renders as “fixed in X; introduction unknown”) — matching semantics decide how to treat the open start, the fact never fabricates an introduced version. - Product-level and ranged facts for the same
(issuer, statement, vuln, product)coexist as distinct facts; the consensus layer prefers ranged facts when both exist, with the preference rule recorded in linkset provenance.
DC-25 — Rename blast radius → stable contract assemblies
Challenge (dig 2026-07-23). 20+ non-test projects OUTSIDE the vulnerability modules compile against StellaOps.Concelier.*/StellaOps.Excititor.*/StellaOps.VexHub.* assemblies: Policy (Engine + 2 libs), Scanner (WebService, Worker, + 6 libs), Cli, SbomService, AdvisoryAI, Findings.VulnCorrelation, Platform.Database, AirGap.Bundle, BinaryIndex.VexBridge, Tools. A brute rename forces a 20-project lockstep change and re-couples every future hub refactor to the estate.
Resolution. The D2 rebuild extracts stable contract assemblies as part of 003 VULN-B1: consumers reference StellaOps.Vulnerabilities.Contracts-class packages (canonical models such as AffectedVersionRange/normalized rules, DTOs, client interfaces) — never service internals. Downstream csproj edits happen once (internals → contracts); after that the hub’s internals rename and refactor freely, forever. The 20-project list is the S0 checklist; the conformance walker gains a rule: no project outside src/Vulnerabilities/ may reference a non-Contracts hub assembly.
DC-26 — Dismantle the Platform central migrator
Challenge (dig 2026-07-23). src/Platform/__Libraries/StellaOps.Platform.Database/ MigrationModulePlugins.cs still declares 24 IMigrationModulePlugin entries across 23 schemas (airgap, authority, binaries, evidence, evidence_locker, export_center, graph, integrations, issuer, notify, packs, platform×2, policy, proofchain, reachgraph, release_orchestrator, remediation, sbom, scheduler, signer, stellaops, timeline×2, unknowns, workflow) — platform-web centrally migrates or co-migrates ~half the estate. PAC-6 (SPRINT_20260703_001) removed only the provably-redundant ones. This machinery is the institutionalized P4 violation: it explains the empty shell schemas in the shared DB (authority, evidence at 0 bytes), it is the sole migrator of workflow (live applied_by = platform-web’s container), and its archived Release migrations created the orphan release.security_finding_projection (X9). Left in place, it silently re-creates schemas in stellaops_platform and races each service’s own migrator after every move.
Resolution. Register entry X15; binding rule in the recipe (S3 + M1): every program removes its module’s plugin(s) from MigrationModulePlugins.cs in the same change that gives the service its own database (delete, never disable). 021 PLT-4 gains the final sweep: only Platform’s own plugins remain, then the plugin mechanism itself is deleted. Programs must treat “Platform also migrates my schema” as a standing hazard until their removal lands.
Runtime faces (dig 2026-07-23) retire with it: Platform’s PlatformMigrationAdminService (RunAsync/GetStatusAsync/VerifyAsync per module) + the /api/v1/admin group (incl. POST /api/v1/admin/seed-demo — its §2.11 disposition is verified during 021), and the CLI’s MigrationCommandService (a manual applier writing schema_migrations rows over the generic connection — retargets per-service against a named owner DB, or retires). Per-service migration status is served by each service’s doctor standard check (P11), not by a central API.
DC-27 — Valkey keyspace/stream namespace ownership
Challenge. All ~40 services share one Valkey with ad-hoc key naming; the estate polices PostgreSQL ownership rigorously while its second shared store has none — prefix collisions, unowned growth, and ad-hoc cross-service key reads are the same disease in a different organ.
Resolution (P16). Every service’s keys/streams live under its own prefix ({service}: keys; streams keep the existing stream:{name} convention with the owning service recorded in the matrix); cross-service stream consumption is a contract (the DC-06/07 envelope), never ad-hoc key access; the doctor standard checks include per-prefix key-count/memory against a P7 budget. Each program’s S8/M3 stage assigns/verifies its prefixes; 021’s final audit greps for unprefixed usage.
DC-28 — Object-store bucket/prefix ownership + budgets
Challenge. The original incident was disk exhaustion; RustFS/object storage (scanner CAS, evidence capsules, advisory-ai outputs, OfflineKit bundle parts) can reproduce it with no per-owner budgets and no ownership register.
Resolution (P16). The ownership matrix gains an object-store section: bucket/prefix → owning service → budget → retention class (same P13 vocabulary); the doctor standard check reports bucket/prefix size against budget; writers pause per P7 before the floor. Cross-service object reads go through the owner’s API or a content-addressed digest contract (ADR-033/035 pattern) — never raw bucket paths.
DC-29 — One outbox lineage: evolve StellaOps.Eventing (kills X17)
Challenge (dig 2026-07-23). The P6 plan said “extend Infrastructure.Postgres with outbox/ inbox/lease” — but an outbox implementation already exists: StellaOps.Eventing ships Outbox/, an EfCore OutboxEntry mapped to timeline.outbox (+ timeline.events), ITimelineEventEmitter, and an ITimelineStreamBridge to StellaOps.Eventing.Messaging — hosted today by VexLens, Findings-Ledger, Concelier (Feedser.Core), and Timeline, all writing into the shared DB’s timeline schema (register X17: cross-service writes via shared lib), with the schema centrally migrated (X15’s EventingMigrationModulePlugin). Building P6 fresh would create a second outbox lineage — the exact fourth-dialect mistake DC-21 forbids — while leaving the “two unbridged pipes” (eventing vs messaging) alive.
Resolution. P6 (003 VULN-B4) is delivered by evolving StellaOps.Eventing, not beside it:
- Per-host homing (P4/X17 fix): the outbox/events tables instantiate in each producer’s own database (library-embedded migrations, host-applied);
timeline.outbox/timeline.eventsin the shared DB retire with the producers’ moves. - DC-06 envelope alignment:
OutboxEntryevolves to carry the envelope (stream, seq, type, v, occurred_at, payload); existing emitters map onto typed events. - The new parts — inbox + durable checkpoint + lease/fencing + content-guard helpers — are added to this lineage (they do not exist today; that is the genuine build).
- Timeline becomes a consumer: the existing
ITimelineStreamBridgeoverEventing.Messaging(router/Valkey) is the ONE pipe (DC-07); Timeline’s store ingests from the stream/inbox instead of sharing producer tables — resolving the historic eventing↔messaging split systemically. - EfCore-vs-raw decision made once inside the lineage during B4 (P13 physical-idempotency requirements decide it); either way there is exactly one outbox implementation in the estate.
DC-30 — One corpus artifact for every matcher (owner-challenged, redesigned 2026-07-23)
Challenge. VulnMatch.Core is hosted by Policy.Engine, the CLI, Integrations, Concelier.Core, and the compact exporter, reading vuln.advisory_affected via IAdvisoryRowSource — inside the release-decision path, where P15 forbids hub queries. The first-draft fix (a Policy-local mutable row projection) was correctly challenged by the owner as vex-duplication 2.0 in embryo: mutable projection tables accrete columns, attract second readers, develop their own lifecycle, and drift.
Resolution (the decades shape). There is already exactly one derived matcher representation in the product: the compact vulnerability corpus (StellaOps.VulnDb.Compact shared library + exporter — the artifact air-gapped estates and the mirror already consume). That artifact becomes the ONE matcher feed for the whole estate:
- The hub exports a content-addressed, generation-stamped corpus artifact per completed generation (the existing VulnDbCompact exporter lineage — evolve, never fork);
corpus.generation.completed(DC-06) carries its digest. - Every matcher host consumes the same artifact through the same
VulnDb.Compactreader implementingIAdvisoryRowSource: Policy (fetch + atomic A/B swap of the active generation, local → P15 holds), Integrations, the CLI, agents, air-gapped estates. Online and offline matching are the SAME code path — the air-gap-first posture makes any other design a permanent two-path drift risk. - Why this cannot become the vex disease: the artifact is immutable per generation and content-addressed — drift is impossible by construction, not by discipline; it has a single producer, a declared derived-rebuildable class, read-only consumers, and whole-generation replacement instead of row mutation.
- Replay dividend: gate verdicts stamp the corpus generation/digest they evaluated against — release decisions become deterministically replayable (the evidence/replay posture gets this for free), and DC-09 readiness is simply “an artifact generation exists.”
- No advisory-row tables exist in
stellaops_policyor anywhere outside the hub. The legacyadvisory_affectedreads (X3 wide-form) die at cutover.
DC-31 — Gate explanations: derive, don’t store (decided 2026-07-23)
Challenge. The open owner decision “gate-explanation retention: full history vs latest” priced at 10–17 GB. The dig killed the premise: the verbose five-JSON explanation payloads have no reader in the estate except their own writer and ONE narrow field (#>> '{winner,sourceKey}', already promoted to an explicit column in the v2 design).
Evidence hardened (round 12): the Console’s explain drawer feeds from policy-engine.client.ts → Policy’s own policy.explanations (policy-run explanations — a different domain, untouched by this decision); no endpoint anywhere serves Concelier’s verbose gate JSON.
Resolution (rule, generalizable): if a value is deterministically re-derivable from content-addressed inputs and has no hot reader, never store it — re-derive on demand. Gate derivation is deterministic by design (input_hash + settings_hash → decision_hash); the hub corpus is generation-addressed (DC-30); so any historical explanation is reproducible exactly (generation + linkset content + settings version → explain). Policy stores the narrow current decision + hashes only; an explain endpoint re-derives verbose output on demand. The vuln_gate_explanation table is removed from the design; the 10–17 GB question is not answered — it is dissolved.
DC-32 — Overlay architecture: two owned stores, one consumption path (round 13)
Challenge. The four-home census (DC-10) needed a real design. The shapes settle it — and overturn the draft placement:
findings.vex_decisions(+vex_override_envelopesDSSE envelopes) is the estate’s richest, evidence-grade tenant-VEX model: subject type/name/digest/sbom-node, status + justification, evidence refs, environment/project scoping, validity windows (valid_not_before/after), attestation refs,signed_override, supersedes-chains — LIST-partitioned by tenant inside the Merkle-anchored Findings ledger. This is a signed decision ledger, not a cache.findings.vex_statement_trust_override(written by Findings-Ledger’sPostgresVexStatementTrustStore) is the per-(tenant, vuln, product, source) trust knob feeding the consensus trust-cap layer.policy.exceptionsis a different domain: pattern-based policy-rule exceptions with a full approval workflow (approver_ids, reason codes, compensating controls, ticket refs, recheck policies).analytics.vex_overridesis a derived attestation cache (confidence/review fields) — rebuildable, dies in 010’s disposition.
Resolution.
- Two systems of record, both control-plane, each in its natural owner: tenant VEX decisions + source-trust overrides stay in the Findings ledger (evolved in 010 — they are signed, attested, Merkle-anchored decisions, exactly what that ledger exists for); policy-rule exceptions stay in Policy (evolved per DC-10).
- One consumption path: the Findings ledger emits overlay events (DC-06 envelope — it is already event-sourced, so the outbox is its natural extension per DC-29); Policy’s gate projector consumes them alongside its own exceptions when deriving verdicts. The hub stays tenant-free; P15 holds (Policy reads only local state at decision time).
policy.vuln_tenant_overlayfrom the earlier draft is demoted from store to inbox materialization of Findings-ledger overlay events — never a competing system of record.analytics.vex_overridesretires (derived); no fifth home may appear (conformance grep).- Dividend: every tenant VEX decision keeps its DSSE/attestation lineage and Merkle anchoring — overlay provenance is evidence-grade by construction, which a fresh Policy-side store would have lost.
DC-33 — BinaryIndex dissolves into the hub (owner-initiated, round 16)
Challenge. 014 planned BinaryIndex+Symbols as a standalone merged service with its own DB. The owner asked the right question: isn’t this hub material? The digs say yes on every criterion:
- Same domain, finer granularity:
vulnerable_buildids,vulnerable_fingerprints,cve_fix_index,binary_vuln_assertion, delta signatures (ADR-022) answer “is this binary vulnerable / does it carry a backported fix” — the hub’s question at binary resolution. All of it tenant-free global knowledge (P12-clean). - It already reinvented hub patterns independently:
corpus_snapshots= generation-stamped, DSSE-signed distro binary-corpus ingest runs — a connector + generation model in the wrong home. - Both of its cross-service contracts become internal: it consumes advisories (fix index, “Resolved from advisory”) and produces signed VEX (VexBridge) — as a hub module these are in-process instead of an artifact contract + first-party issuer API.
- Blast radius points hub-ward: fingerprint corpora are growth-prone global data — exactly what the vulndata cluster isolates; parking them on the control-plane cluster re-plants the seed.
Resolution. BinaryIndex’s knowledge plane becomes hub modules:
- Distro binary-corpus ingestion → a connector family (Tier-0-class, generation-marked like every source).
- Binary-level applicability → a fact class (
binary_fact: buildid/fingerprint → vuln, with provenance incl. its DSSE lineage; D4 discipline,scheme: buildid|fingerprint). - Fingerprint/fix-index data stays in plugin-owned hub tables served via API — (superseded 2026-07-23 by the round-18 DC-30 amendment below: fingerprint corpora are excluded from the compact artifact because they would multiply its size; binary analysis runs hub-side.) Consequence: Scanner’s binary matching becomes a hub-API dependency at scan time (not gate time — P15 untouched); 014/017 state the degraded behavior when the hub is unreachable.
- VexBridge becomes an internal deriver (binary-match evidence → hub facts with provenance); the first-party issuer API remains for other internal issuers.
- Symbols comes along as a hub-adjacent global CAS (catalog/manifests in hub tables, blobs to the object store per P16) — it serves binary analysis, is tenant-free, and is too small to be a deployable.
stellaops_binaryindexis removed from the target topology; program 014 re-scopes to “knowledge-plane dissolution” executing after the hub foundation (003–005); thebinaryindex-web/symbolsdeployables retire.
DC-34 — SbomService is the SBOM system of record (round 16)
Challenge. SBOM data is scattered across FOUR homes today: Scanner’s registry/artifact_boms, SbomService’s sbom schema, vuln.sbom_registry (in the hub schema!), and Findings.Security’s security_sbom_component/graph_projection — the shared-DB disease in miniature. Round 15 assigned sbom_registry to the Findings family; deeper thought corrects that: a registry of SBOMs is inventory, not match results.
Resolution. The SBOM domain gets one owner — the service named for it:
- SbomService = system of record for SBOM inventory: digest-keyed, content-addressed (same digest ⇒ same SBOM — the content is objective; visibility is tenant-scoped ACL), blobs in the object store (P16; ADR-035 digest-centric pattern), metadata/catalog in
stellaops_sbomservice. - All producers publish to it: Scanner’s scan-generated SBOMs (event/API — Scanner keeps only transient scan-execution artifacts), BYOS uploads (already there), CI/API submissions, OfflineKit imports.
- All consumers subscribe:
sbom.uploaded/updatedevents (DC-06) + digest API. Findings computes matches on the corpus artifact from these events (sbom_canonical_match= match RESULTS, correctly Findings-owned per round 15); its component/graph projections are declared derived-rebuildable. - Re-homings:
vuln.sbom_registry→ SbomService (round-15 correction: it was never Findings material); Scanner’s SBOM-registry tables → SbomService at the 017 move. - Why standalone (not Scanner, not Findings, not hub): multiple independent producers (BYOS and CI SBOMs are not scans — Scanner would be consumer-homing #P14); artifact-scoped with tenant ACL (hub is tenant-free — P12); evidence-grade lifecycle (immutable per digest, long retention, feeds evidence bundles) distinct from both scan compute and finding triage.
DC-35 — One lease lineage (round 17)
Challenge. The estate already runs three independent lock/lease implementations, none with fencing tokens: scheduler.locks (lock_key, holder_id, acquired_at, expires_at — a real renewable lease) alongside scheduler.workers (a heartbeat worker registry with capacity accounting); notify.locks (~10k rows); and workflow.wf_host_locks. P6’s plan to “add lease/fencing” as new primitives would create implementation #4 — and every existing one carries the classic unfenced-lease defect (an expired holder can still commit).
Resolution.
- The P6 lease primitive (built in 003 VULN-B4 inside the Eventing lineage per DC-29) is the one lease implementation: renewable lease + fencing token checked inside the destination transaction — the part none of the existing three have.
- Existing lock tables reconcile during their owners’ programs: JobEngine (012) either adopts the P6 lease for job/worker coordination or documents
scheduler.locksas a local job-lock with a written rationale (its worker-registry/heartbeat model is a legitimate separate concern); Notify (015) same treatment fornotify.locks;wf_host_locksfollows the Workflow disposition (020 W3-15). - Rule going forward: no service introduces its own lock table; conformance greps for new
*lockstables outside the P6 primitive.
Meta-note: this is the strongest confirmation of the standing meta-lesson — even the “genuinely new” parts of P6 (lease/fencing) had three partial lineages hiding in the estate; the new part is exactly and only the fencing token.
DC-36 — Replayability = derive-on-demand over the hub’s own history (owner-shaped, round 22)
Challenge. Verdicts stamp their corpus generation (DC-30) and explanations re-derive from generation content (DC-31) — but the round-20 review found nothing retains generations, so historical replay would break on any hub rebuild. The architect’s first proposal (archive generation artifacts in the evidence plane) was rejected by the owner: “replayability should generate the fact on the API response — it reproduces by replaying; why record replayed artifacts?” Recording derived artifacts would re-bless derived state as a source — the exact habit D9 removed.
Resolution (the owner’s philosophy, applied). The re-derivation substrate is what the hub already keeps as source truth: content-addressed raw documents (ingest, immutable) + append-only fact_revision + generation markers. Everything else is re-derived on demand:
- Corpus-as-of-G is a query over
facts⊕fact_revision≤ G; the generation artifact for G is re-generated deterministically from it — its content-addressed digest must equal the stamped digest (integrity proof for free). No stored artifact is ever the source. - Replay is an API whose response is the proof: a replay request (Policy
explain/replay; the existing/api/v1/tester/replay-and-compareseam; the Replay service as verifier) re-derives the verdict/explanation from (corpus-as-of-G × policy bundle hash × settings) and emits the comparison result as a new fact/attestation — replayed artifacts are never recorded, replay events are. - The replayability horizon is explicit config: raw-document and
fact_revisionretention (P13 append-audit windows) SET the horizon — retain ≥ verdict retention for full replay; the budget line for revision growth is recorded in design §9 (narrow rows; bounded by upstream churn). Beyond the horizon, replay degrades honestly to integrity verification (digests still prove what was used; re-derivation is refused, never faked). - DR preserves the horizon through mirror bundles: bundle export carries raw docs and revision history (T9 extended — round-trip reproduces fact and revision hashes), so hub loss + re-import restores replayability; connected estates keep last-N bundles (025 retention class). Re-ingest-from-live-sources alone restores current state, not history — stated, not hidden.
- Cross-version honesty (DC-39 seam): the generation stamp records the exporter/format version; byte-digest equality is asserted within the same version, semantic (fact-level) equality across versions.
DC-37 — Estate restore doctrine (owner: “think carefully”, round 22)
Challenge. ~20 databases with causal links (inbox checkpoints, catalog replicas, Merkle ledgers vs projections); P9 was one sentence and ADR-004’s snapshot-restore runbook predates the multi-DB estate. Restoring one database out of twenty is the case that ruins estates quietly.
Resolution.
- Every database declares a restore class (matrix column, from the P13 vocabulary):
backup-restore(source-of-truth control plane: Authority, Platform, Policy, Findings ledger, Evidence, JobEngine, Notify, RO, Scanner, SbomService…),re-project(pure read-models: rebuild from owner events/artifact instead of restoring),re-ingest(the hub: sources + mirror bundles per DC-36.4). - Producer restore ⇒ epoch bump ⇒ consumers self-converge (DC-06/07): the epoch mechanism makes restore-ORDER largely irrelevant — each consumer detects the epoch change and re-syncs via pull-then-subscribe, regardless of sequencing. The only fixed points: Authority first (identity root — nothing can pull without tokens), Valkey/transport up before consumers (transport loss itself is recoverable — outbox is the source, DC-07).
- Consumer restore is self-healing by construction: an inbox checkpoint behind the producer’s outbox replays forward idempotently (P6); a checkpoint AHEAD is impossible within one epoch and detected across epochs.
- Ledger-class stores verify on restore: Findings recomputes per-tenant Merkle roots (010’s gate); Evidence recomputes verdict-chain linkage (DC-16) — a restored ledger that fails chain verification is quarantined, never served.
- Drills are stages, not documentation: the recipe gains a restore-drill evidence item (S9/M4: restore the just-moved database from backup on the scratch stack; consumers converge; forcing function passes) and 021’s final audit requires one full-estate restore rehearsal record.
- The migration-recovery runbook is rewritten to per-service scope (restore class + epoch steps + verification per family) as part of this doctrine landing; cross-service consistency after a partial restore is reached by convergence (re-sync + re-project), never by coordinated point-in-time snapshots across databases — the estate explicitly does not promise cross-service PITR.
DC-38 — One consensus algebra: evaluator library + sectioned artifact (owner-approved, round 22)
Challenge. Consensus is hub-global and tenant-free (P12); trust overrides are tenant-scoped in the Findings ledger (DC-32). WHERE a tenant override modifies effective consensus was undefined — risking a second consensus implementation in Policy (the two-code-paths drift DC-30 killed for matching) or overrides that silently under-deliver (the historic trust-cap-neutralization bugs). Owner approved the library and asked: how is the library fed, and does the data layer need further optimization?
Resolution.
- One evaluator library (
StellaOps.VulnConsensus-class, evolved from the VexLens/hub consensus lineage — never a fork): a pure functionevaluate(consensus_inputs, trust_config, overlays[]) → effective consensus, generation-stamped and deterministic. The hub computes GLOBAL consensus with global trust config (itsconsensusschema stays the global projection); Policy computes TENANT-EFFECTIVE consensus with tenant deltas — same code, same inputs shape. - The feed is what each host already has locally — no new pipes: (a) the corpus artifact gains a
consensus-inputssection (per-issue statement summaries: issuer, status, justification, trust tier — narrow rows) — every matcher host already receives the artifact via DC-30 A/B swap; (b) tenant overlays arrive as Findings-ledger events into Policy’s inbox materialization (DC-32 — already local); © global trust config rides the artifact manifest. P15 holds by construction: evaluation reads only local state. - Data-layer optimization: the artifact becomes SECTIONED — one generation manifest of content-addressed parts (
matcher-rows,consensus-inputs,exploit-evidence,reachability-sinks), consumers declare and fetch only the sections they use (CLI/Integrations skip consensus; Policy takes all). Same envelope philosophy as DC-18 parts; one generation identity = the manifest digest. This caps per-host disk (the round-20 disk-multiplication concern) without a second artifact format. - Nothing tenant-effective is persisted as a system of record: Policy derives per evaluation (bounded cache at most); the Findings ledger stays the overlay SoR; the hub stays tenant-free. Conformance: no second consensus implementation (grep + a shared fixture corpus that hub and Policy must both reproduce bit-identically).
DC-39 — Contract versions at the artifact and plugin seams (owner-approved, round 22)
Challenge. DC-06 versions events; nothing versioned the two other seams that cross deployment boundaries. Evidence: two live defect classes (Concelier cross-ALC identity; the scanner analyzer-bundle skew of SPRINT_20260723_002) — and the program multiplies plugin families (hub connectors, doctor plugins in every service, export profile executors, crypto plugins).
Resolution.
- Artifact: every generation manifest carries
format_version(CompactVulnDbSchema.Versionlineage) per section; readers implement an n/n-1 compatibility window (consume version v and v-1; refuse older/newer with an explicit, operator-visible error — never silent misparse). Air-gapped estates are the design case: reader and artifact upgrade at different cadences by construction. - Plugins: every plugin bundle manifest records the contract-assembly versions it was built against; the host verifies at load and refuses (or degrades loudly, per plugin criticality) on mismatch; the doctor standard checks include a bundle-skew probe (bundle contract version vs host contract version). The 009 DOC-1 “contract versioned” requirement is the template — extended to every plugin family. Deploy tooling (
build-service-publish.shlineage) rebuilds bundles with the image, never out-of-band. - Conformance: a skew-injection test per plugin host family (stale bundle ⇒ load refused with the named error, not
MissingMethodExceptionat first use).
Round-18 amendments (developer walk-through of the recent programs)
Round 18 re-read the newest sprints as the implementing developer (“what blocks me on day 1 that the sprint doesn’t answer?”). Amendments to standing challenges:
- DC-13 (amended): for evaluation-class jobs the idempotency key includes **corpus generation
- policy bundle hash** (else post-update re-requests dedup onto stale verdicts). Estate precedent:
attestor.verdict_ledgerstampspolicy_bundle_id/hashper row.
- policy bundle hash** (else post-update re-requests dedup onto stale verdicts). Estate precedent:
- DC-30 (amended — artifact residency): matcher hosts cache the artifact on a named volume and record the active generation in their own DB; restart ⇒ digest-verify or refetch; lost-volume + hub-down ⇒
indeterminate(DC-09). Decision-time independence (P15) is absolute; restart-time refetch is an accepted visible dependency. Compact artifact content stays advisory/VEX/range-only — binary fingerprint corpora are excluded by decision (014). (Round-22 precision: DC-38 supersedes the “advisory/VEX/range-only” phrasing — the artifact is SECTIONED (matcher-rows / consensus-inputs / exploit-evidence / reachability-sinks); binary fingerprint corpora remain excluded.) - DC-23 (amended — sealed semantics made precise): sealed = online connector fetch/egress blocked; bundle import allowed + freshness-gated (time anchor vs staleness budget). A 025 completion criterion had this backwards (“seal→import-blocked”) — fixed; kept here as the cautionary example that posture semantics must be stated as a forcing PAIR (what blocks AND what must keep working).
- DC-12 (amended — auth): the catalog-replication pull API is guarded by a dedicated machine scope
authority:catalog.replicate(service clients only), not by console-facingauthority:tenants.read.
