Skip to content

Architecture Decision Rulings — R42 Phase 2

Architecture Decision Rulings — R42 Phase 2

Date: 2026-08-12 · Produces: ADR-174, ADR-175, ADR-176 · Findings: F-R42-01F-R42-07

There is no R41. The R sequence is a filing convention rather than a contiguous range — docs/README.md records the same about the absent R10. R42 Phase 1 (the Actions-budget work, PRs #85, #88 and #89) produced no ADR by design: moving a job to a cheaper runner neither contradicts a decision nor rejects a re-proposable alternative, which is ADR-045’s criterion.

This round is not an audit. It is a build round for supply-chain controls, and its three decisions are unusual in this corpus for being mostly configuration: nothing here changes an architectural boundary and nothing weakens a gate. What makes them ADRs rather than directives is that two of them refuse something the brief asked for, on evidence, and one re-opens a decision that was deliberately deferred and rules on it.


0. What this round found before it built anything

Four of the round’s seven findings are about what this GitHub plan cannot do, and they were established with API calls rather than assumed. They are first because three of them delete work the brief specified.

FindingEvidence
F-R42-01Artifact attestations are unavailable. They require a GitHub Enterprise Cloud plan for private repositoriesgh api orgs/DescentVTTplan.name: "free"; repository private: true; actions/attest-build-provenance’s own documentation states the requirement
F-R42-02Code scanning / the Security tab is unavailable. Needs GitHub Advanced Securitygh api .../code-scanning/alerts403 Advanced Security must be enabled for this repository to use code scanning
F-R42-03The only disclosure route this repository publishes does not exist, and it is worse than absentcore/Descent.Sandbox/SECURITY.md directs researchers to GitHub private vulnerability reporting, which is public-repositories-only; gh api .../private-vulnerability-reporting404
F-R42-04github-workflow.md §5 asserts an enforcement point that has never existed“CI fails the build on a security-tagged advisory in either .NET core engine, and enforces a minimum engine revision per release”No workflow contains any advisory check. Searched all ten
F-R42-05The determinism-surface crate had no committed Cargo.lock, and two CI cache keys hashed that non-existent pathcore/Descent.Geometry/.gitignore line 2; ci.yml lines 93 and 176
F-R42-06github-workflow.md §5 says there are three package hubs; ADR-162 deleted Descent.RngKit and there are twofind . -name Directory.Packages.props
F-R42-07core/Descent.Sandbox/SECURITY.md pins Jint 4.13.0 and declares findings against any other version out of scope; the tree is on 4.15.3That file’s Supported versions, against core/Descent.Sandbox/Directory.Packages.props line 79

F-R42-03 and F-R42-07 are the same shape and it is the shape this corpus keeps rediscovering: a document that was true when written, in a repository that moved underneath it. The sandbox’s security policy was written for a standalone public MIT module. The convergence made the repository private and ADR-111 made the engines proprietary; neither change re-read a file whose subject is how a stranger reports a vulnerability. Between them, the published policy names a reporting route that returns 404 and declares the shipped interpreter version out of scope.

F-R42-04 deserves its own sentence, because it is the one this round was chartered to fix and did not know it would find. A rules file claimed CI failed the build on a security advisory. It never did. That claim has been sitting in github-workflow.md §5 as a description of an existing control, which is the failure mode docs/README.md lists as recurring failure mode 6 — a rule satisfiable by writing a sentence is not enforced — except worse, because this sentence did not even state a rule. It stated that a mechanism existed.


1. ADR-174 — Supply chain controls

Status: Accepted · Date: 2026-08-12

Context

This platform signs its creators’ artefacts (ADR-136), distributes third-party UGC, executes untrusted WebAssembly in two hosts, and pins every dependency centrally with recorded reasons. It did not produce an SBOM, scan any dependency for known vulnerabilities, or have any automated dependency updates. tech-stack-currency.md is a hand-maintained register whose own §3c records a row that was “wrong in both clauses” — catching a new upstream release is the job a bot does well and a human does badly.

Decision

Four controls, and the tier each lands in is stated because P3 ranks them:

ControlTierEnforcement point
Renovate opens PRs; merges nothingA scanner that reports — the weakest tier, and correctly sorenovate.json5. Its automerge: false is global with no rule overriding it
cargo-deny licences / bans / sourcesA gate that failssupply-chain.yml job scan, exit code
osv-scanner advisories, held against a two-sided ledgerA gate that failstools/supply-chain/osv-ledger-gate.mjs
NuGet advisoriesA gate that fails, hard, from day onetools/supply-chain/nuget-vulnerability-gate.mjs
SBOM (CycloneDX + SPDX)Description — nothing consumes it yetsupply-chain.yml job sbom; the component floor is the only assertion

The .NET LTS directive is encoded, not remembered. allowedVersions: '<11.0.0' on the four package families version-aligned with the runtime major. P3 puts a reviewer’s memory at the bottom of the ordering, and .NET 11 is an STS release ending support on the same date as the LTS already in use.

The division of labour is not the obvious one, and both departures were forced

The brief proposed osv-scanner as the single scanner and cargo-deny for Rust including advisories. Two measurements changed that:

  1. cargo-deny does not run its advisories check. Version 0.19.1 panics parsing the current RustSec database — failed to parse advisory from '.../RUSTSEC-2020-0105.md': failed to find toml block — reproducibly, on all eleven manifests. licenses, bans and sources are unaffected and exit 0. osv-scanner reads Cargo.lock natively, so the axis is covered; the workflow records this so the next author does not “fix” a missing check by re-adding an argument that aborts the job.

  2. cargo-deny is retained for LICENCES, which osv-scanner cannot see at all. ADR-111 made the three core engines proprietary and Rust links statically, so a copyleft transitive crate is a licence obligation on a closed-source binary arriving through a dependency nobody chose. The allow-list fails closed: a deny-list of known-copyleft identifiers passes an unfamiliar one, and the first anyone would learn of it is a licence review before a release.

The baseline was measured before anything was gated

“A CI that is always red is no CI at all” is a failure mode this corpus already records, so the backlog was established first, across 3 pnpm-lock.yaml (1,175 packages) and 12 Cargo.lock:

  • npm registry: clean, all three roots.
  • cargo-deny: clean on all eleven manifests.
  • NuGet: ZERO vulnerable packages, all four solutions, direct and transitive.
  • crates.io: THREE advisories over two packages, none of them fixable here. lru 0.12.5 through tantivy 0.25.0; paste 1.0.15 through boa_engine 0.21.1.

That result splits the decision, and the split is the interesting part. NuGet has no backlog, so it is a hard gate on day one with no ignore file and no severity threshold — building a suppression mechanism before the thing it suppresses is building the escape hatch first. crates.io has a real backlog that is someone else’s to fix, so it gets a two-sided ledger: a new finding is red, and a ledger line that no longer fires is also red. osv-scanner’s own [[IgnoredVulns]] is one-sided, which is how a suppression file fills with entries that stopped being true; enforcement_baseline.txt states the same rule for a demoted MUST.

The gate caught a High-severity advisory on its first live run, fourteen minutes after it was published

This is the strongest evidence the round produced, and it was not planned.

The local baseline above measured zero vulnerable NuGet packages. Its first run in CI, hours later, failed:

[High] SSH.NET 2025.1.0 (transitive)
in tests/core/Descent.IntegrationTests/Descent.IntegrationTests.csproj (net10.0)
https://github.com/advisories/GHSA-q939-rpr3-3284

GHSA-q939-rpr3-3284ScpClient recursive download allows arbitrary file write via server-controlled SCP filenames — was published 2026-08-12T15:19:10Z, and the job failed on it at 15:33Z. The local baseline was therefore correct when it was taken; the advisory did not exist yet. A baseline is a reading with a timestamp, not a standing fact, and this is the cleanest possible demonstration of why the gate’s own failure message says “the baseline measured on 2026-08-12 was zero, so a finding here is NEW rather than inherited” rather than “there are no findings”.

And the remedy is the first exercise of ADR-176, decided hours earlier for an unrelated reason. The path is Testcontainers.PostgreSql 4.13.0 → Testcontainers 4.13.0 → SSH.NET 2025.1.0, and Testcontainers has not moved — 4.13.0 is still latest, so the ordinary remedy of taking an upstream bump does not exist. Without CentralPackageTransitivePinningEnabled the only options would have been to wait for a third party or to accept a High-severity advisory on the ledger. With it, one PackageVersion entry for a package no project references pins the transitive to the patched 2026.0.0 and the graph resolves clean today.

Verified rather than assumed, because 2025.1.0 → 2026.0.0 is a major bump of a library Testcontainers calls: Descent.IntegrationTests runs 232 tests, 0 failed, against real postgres:18-alpine containers.

The baseline run caught the configuration being wrong

With allow-wildcard-paths = false, cargo deny check bans failed on 2 of 11 manifests. Every finding was of the shape descent-geometry-oracle = { path = "oracle" } — an intra-workspace path dependency, which carries no version field and is therefore reported as a wildcard. It is not a supply-chain edge. Denying it would have made two manifests permanently red for a property neither violates: the always-red build, produced by the config written to prevent it. Recorded because the gap between “I configured a scanner” and “I ran it against this repository first” is exactly one finding wide.

Alternatives considered

  • Dependabot instead of Renovate. Rejected on configuration expressiveness, which is most of the work here: Dependabot cannot express allowedVersions, has no equivalent of the held-pin description that surfaces on a dashboard, and groups less flexibly. This repository has eleven deliberate holds and a monorepo with two NuGet hubs, three pnpm roots and eleven cargo manifests.
  • pnpm audit instead of osv-scanner. Rejected: it needs its own ignore file, and osv-scanner covers crates.io as well, which makes it one tool for two ecosystems.
  • cargo-audit beside cargo-deny. Rejected as unnecessary once osv-scanner covers the Rust advisory axis. It would be a third advisory database for the same crates.
  • Staying put. The real alternative and it is what F-R42-04 refutes: a rules file already claimed this control existed, so “we would notice” was already the recorded position and was already false.
  • cosign / Sigstore keyless signing, as a substitute for the unavailable attestations. Rejected, and not on cost. Keyless signing publishes to the public Rekor transparency log, and the certificate carries the repository URI, the workflow path and the commit SHA. That publishes the structure of a private repository to an append-only public log that cannot be retracted. For a platform whose §8.2 is about not disclosing things, adopting it silently to fill a gap would be the wrong trade made quietly.

Consequences, including the negative ones

  • Our own builds have no provenance and the SBOM is unsigned and unbound to a build (F-R42-01). An SBOM of a source tree is not an SBOM of an artefact, and nothing here changes that.
  • Renovate PRs cost real money. One touching Directory.Packages.props triggers ci, studio-ci and marketplace-ci — about $0.50 of Actions time, against an allowance already exceeded ~11x. Hence a weekly window and prConcurrentLimit: 3, which looks unresponsive and is deliberate.
  • The scanners are themselves dependencies, pinned by version in the workflow, with seam rows in upgrade-and-supersession.md §4 (P8).
  • Nothing in this ADR verifies an artefact anyone consumes, because no artefact is consumed yet.

Rights-holders (ADR-079)

An SBOM and a scan report retain a new class of data: the dependency graph, and — in the workflow run metadata GitHub keeps regardless — the identity of whoever triggered a build. The answer is “nobody besides the platform”: SBOMs are workflow artefacts with 90-day retention, visible to repository members only, and the repository is private. No SBOM is published to any third party today. This answer changes the day one is handed to a market surveillance authority, which is the point of recording it now rather than leaving the cell blank.

Enforcement

.github/workflows/supply-chain.yml. Every clause above names a job or a script exit code except the SBOM, which is written as description because nothing consumes it.


2. ADR-175 — The CRA technical half

Status: Accepted · Date: 2026-08-12

Context

The EU Cyber Resilience Act’s vulnerability and incident reporting obligations begin 2026-09-11; full compliance including CE marking follows 2027-12-11. They attach to a product placed on the market.

The brief that commissioned this treated that date as a thirty-day deadline binding this platform. One command falsified it: gh release list and git tag --list are both empty. Nothing has ever been released. With nothing placed on any market, there is no subject for the obligations to attach to.

What survives, and is why the work was done anyway: the obligation applies from the first shipment with no grace period, and Studio is one round from deliverable. A disclosure policy and an SBOM have to already exist on the day a creator downloads a binary; they are not something you begin writing then.

Decision

Build the technical half; write the legal half as questions. The split follows the pattern OI-M’s four legal blockers already set — acknowledged but require external counsel.

Delivered: SECURITY.md, .well-known/security.txt, docs/Security_Vulnerability_Triage_Runbook.md, docs/CRA_Counsel_Questions.md.

Three judgements worth the space

1. The acknowledgement window is 72 hours, not 24. An acknowledgement window is a promise that a human is available. A 24-hour promise from a project this size is one that would sometimes break, and a policy with an unmonitored inbox is worse than none — it converts a researcher’s goodwill into a public disclosure when nobody answers. The 24-hour figure in CRA discussion is a different clock: manufacturer-to-authority, for a vulnerability actively exploited in the wild. The runbook’s §1 keeps them apart, because conflating them fails in both directions — starting a regulatory clock for an ordinary bug report, or missing one that had actually started.

2. The support period is a proposal with a constraint attached, not a question. net10.0 is supported to November 2028, so no published support period may outlive it without a runtime plan that does not exist. 2028-11-30 is what that produces. The LTS directive tightens rather than loosens it: .NET 11 is STS and ends on the same date, so moving this date requires .NET 12 LTS.

3. The safe-harbour text and the support periods are DRAFTED, and the file says so in a banner. Both are legal-ish undertakings written by an agent. P9’s rule — where a cost is real, write it down — applies to who drafted them, and the cost is that a researcher reading a published policy reasonably relies on it. They need owner sign-off before the file is treated as published.

What is not enforced, stated rather than implied

The contact inbox is an operational commitment nothing in this repository can observe. No CI check, metric or SLO can tell whether contact@descentvtt.com is being read. Under P4 the 72-hour window is therefore description, not a requirement, and the honest consequence is that it rests on a person rather than on a control. A dedicated security@ alias is the usual practice and is recorded as an option for the owner; it was not made a blocker.

The runbook has never been executed. §10.1.1’s principle for disaster recovery applies unchanged: a runbook nobody has executed is a document, not a capability. §6 is a tabletop exercise with a deliberately borderline scenario, and its output is a list of questions that could not be answered, recorded as OI-V-16.

Consequences

  • security.txt serves nothing: none of the three candidate surfaces is public. Publishing it is a release-checklist item (OI-V-15), and its Expires field makes it dangerous to forget — a lapsed security.txt still being served is formally void and reads as an abandoned programme.
  • SBOM retention is 90 days against the CRA’s ten years (OI-V-14). GitHub artefacts are not an archive and this ADR does not pretend otherwise.
  • F-R42-03 is recorded, not fixed in place. core/Descent.Sandbox/SECURITY.md’s reporting route is corrected in the root SECURITY.md because the two documents have different audiences and the stale one is what a sandbox researcher meets first.

Rights-holders (ADR-079)

A vulnerability report contains a reporter’s identity and contact details, retained for the life of the triage and — if Q7 is answered that way — potentially ten years. Recorded as unresolved rather than answered: CRA_Counsel_Questions.md Q7 asks it explicitly, and notes that a ten-year retention obligation and the data-minimisation posture the Marketplace corpus takes elsewhere may pull in opposite directions.

Enforcement

None of it, and that is stated rather than implied. Every clause is a working agreement under P4. The one mechanised part is the scanning that would surface a vulnerable dependency in the first place, which is ADR-174’s.


3. ADR-176 — Central transitive pinning, and NuGet lock files

Status: Accepted · Date: 2026-08-12

Context

Directory.Packages.props recorded, since 2026-08-08: CentralPackageTransitivePinningEnabled is not set… Turning it on… would need its own verification pass against the parity corpus and the Native AOT publish gates.” That pass had never been scheduled. ADR-174 gave it a reason to be: both osv-scanner and syft read packages.lock.json for .NET, and without one they see nothing at all of the .NET graph.

The measurement

Run 2026-08-12 on win-x64:

Result
restore Core + Marketplace, both flagsexit 0, no NU diagnostics — in particular no NU1109, so nothing was downgraded
build Core -c Debug0 warnings, 0 errors
test Core978 passed, 0 failed, including the Testcontainers postgres:18-alpine suite
publish Descent.Marketplace.Api -r win-x64exit 0, 33.6 MB native binary, IlcTreatWarningsAsErrors on → zero IL2xxx/IL3xxx
publish Descent.Studio.Launchpad -r win-x64exit 0, native code generated

The deferral named the parity corpus, and checking rather than assuming showed it was never the binding obligation. The corpus is cargo test --test parity_corpus in core/Descent.Geometry — a Rust suite with no NuGet graph. The AOT publish gates were the real condition, and both pass. This is P2 producing a small correction to a three-day-old note: an obligation can be discharged by establishing it does not apply, provided you say which one you checked.

Decision, in two halves that go different ways

Transitive pinning: ON. What it does is narrower than “promotes every transitive to a direct pin” — it pins a transitive only where a PackageVersion for that id already exists in a hub, 251 such promotions across the tree. The effect is that Directory.Packages.props becomes the single answer for every package it names, in every position it appears.

Lock files: NOT COMMITTED, and this is a refusal on a measurement rather than caution. They are genuinely valuable — with them osv-scanner reads 124 packages in the silo alone and syft resolves 317 NuGet components where it otherwise resolves ZERO, which is the hole in this platform’s SBOM.

What stops them is the RID matrix. A RID-specific restore adds a net10.0/<rid> section: publishing the Launchpad for win-x64 produced ['net10.0', 'net10.0/win-x64']. studio-ci publishes win-x64 and linux-x64, and marketplace-ci publishes linux-x64 — so a lock committed from any one machine carries that machine’s RID section and no other. Under --locked-mode the other legs fail; without it, restore rewrites the file and CI carries a dirty tree, which is a lock file that locks nothing.

So the SBOM’s NuGet graph is obtained by generating lock files inside supply-chain.yml and throwing them away. Full visibility, no multi-RID problem, nothing committed that one platform can invalidate.

Alternatives considered

  • Commit lock files and add --locked-mode. Rejected above. The trigger that would reopen it: a single-RID publish path, or a dotnet restore that can populate every RID section from one host.
  • Lock files only for non-RID-published projects. Rejected as backwards: it would give the SBOM every project except the ones that ship, and the shipped binaries are what a CRA SBOM is about.
  • Neither flag. The status quo, and it fails ADR-174: without lock files the SBOM has a .NET-shaped hole, and that hole covers both products that will actually be placed on a market.

Consequences

  • A transitive with no entry in a hub is untouched, so this is not full transitive pinning and the ADR does not claim it is.
  • Directory.Packages.props is now load-bearing in a second way: a version there decides the transitive resolution too, so a bump has a wider blast radius than before.
  • The .NET-graph half of the SBOM is regenerated per run and is not reproducible from the repository alone — a fair criticism, and the honest counter is that it is derived from Directory.Packages.props, which is committed.

Enforcement

Directory.Packages.props, which fails the restore rather than a review (NU1008/NU1109 are the diagnostics). The lock-file half is enforced by supply-chain.yml’s SBOM component floor: if the generation step stops working, the SBOM shrinks and the assertion fails.


4. The Actions budget — measured, and three refusals

Not an ADR. ADR-045’s criterion is that an ADR contradicts or narrows a decision and rejects a re-proposable alternative; moving a job to a cheaper runner is neither. Recorded here because the figures are the round’s, and because every cost figure in the brief was stale for the fourth time in three days.

The measurement, with its workload snapshot

Method matters and the first attempt was wrong. Sampling “the most recent 40 runs” gave a platform split of 43% macOS / 38% Linux — because the sample was dominated by studio-ci, which is 16 of 856 runs but was 12 of the 40 sampled. The figures below sample per workflow and weight by that workflow’s actual run count.

Seven days to 2026-08-12, 856 runs. Rates are the brief’s paid per-minute figures.

WorkflowRunsmin/run$/run$/weekshare
ci2209.40.05712.4635.8%
frontend-ci12413.50.08110.0729.0%
sandbox-ci427.50.1094.5813.2%
studio-ci1613.20.2594.1511.9%
marketplace-ci624.10.0241.514.3%
geometry-fuzz217.30.0440.922.6%
everything else3711.073.1%

$34.75/week including macOS runs that predate PR #89. Removing those — macOS is now off every automatic path — gives the current configuration:

≈US$127/month. Linux ≈91% of the cash. Windows ≈9%. 5,046 included-minute equivalents per week → ~21,600/month against a 2,000 allowance: ~10.8x over.

The brief projected US$104/month arithmetically; the measurement says $127. Both are recorded so the difference is visible rather than silently rebased.

The three refusals, each with the number that refused it

Sub-itemMeasured savingWhy refused
Move studio-ci Build & Test to ubuntu2.21 min/run at $0.0221, 16 runs/wk → ≈$0.55/monthWindows is the only platform on which ModuleProcessTests drives real stdio pipes and LocalSiloHostTests binds a real Kestrel port, on a Windows-first product. Pipe semantics are exactly where platform matters. 55 cents is not worth the only coverage of that seam
Move sandbox-ci’s Windows leg off PRs3.81 min/run at $0.0381, 42 runs/wk → ≈$6.86/monthThe file states the reason itself: the stack-exhaustion defence is sized against the 1 MB default thread stack Windows gives a ThreadPool thread. Removing it leaves a containment ceiling unverified on the platform whose constant it is sized to
Skip draft pull requests$0.00Zero drafts across 60 pull requests. It would save nothing and carries a real trap: ready_for_review is not in the default pull_request trigger set, so a draft marked ready would get no run at all and the merge would happen on no signal

That is the brief’s own conclusion 1 landing harder than it stated it. The platform multipliers are not where the money is; two of these three cost more than they save, and the third saves nothing.

What did land

dotnet-stryker pinned at 4.16.0. Unpinned it installed whatever was newest that morning — and this job’s verdict is a mutation score against a break threshold, so an engine that moved underneath it shifts the score with nothing in the diff to explain it, and a drop reads as the tests having got worse. Same defect R42 Phase 1 fixed for cargo-fuzz one file over.

The structural lever, and why it is not taken here

A Linux runner removes ~91% of the bill — about US$115/month. It is the dominant lever by a wide margin and everything above it is worth doing because it is free, not because it is large.

It is not adopted by this round, and the reason is that it is not this round’s to take. It needs a machine, a runner registration token, and an owner decision about trust: a self-hosted runner changes the hermeticity property CI currently gets for free, must never be attached to a public repository, and must not share a CPU with a developer’s build or a parallel wave of agent sessionsOpen_Questions.txt already records a sandbox test failing at ~68 ms against a 50 ms budget purely from contention, and §14.9 forbids the easy escape of retrying it.

Recorded as OI-V-17 with the measurement attached, so the decision is costed rather than re-derived. A merge queue is separately blocked: it requires a branch ruleset, and gh api repos/.../rulesets returns 403 Upgrade to GitHub Pro or make this repository public.


5. What this round did NOT do

Longer than usual, and stated plainly (P7):

  1. Nothing here has run in CI. Every measurement above was taken locally. supply-chain.yml is unexercised until its first push.
  2. No artefact is signed and none carries provenance. F-R42-01. SLSA Build Level 2 and 3 were both out of reach, so the brief’s L2-versus-L3 question has no answer rather than a compromise one.
  3. No OpenSSF Scorecard. Its output is a published score, and both publication routes — the OpenSSF API and the Security tab — require a public repository or GHAS. Running it to produce a number nobody can see was judged not worth the minutes on an exhausted budget. This is the one brief item declined on judgement rather than on a hard block, and it is flagged as such.
  4. F-R42-07 is recorded and not fixed. The Sandbox’s SECURITY.md declares Jint 4.13.0 in scope against a tree on 4.15.3. Fixing it means deciding what that policy now is for a private proprietary engine, which is a question for whoever owns that module rather than a version bump.
  5. The triage runbook has never been executed, and the tabletop in its §6 has not been run.
  6. The SBOM covers a source tree, not an artefact, and has a ten-year retention gap (OI-V-14).
  7. Renovate is configured but not installed. Enabling the GitHub App is an owner action on the organisation; nothing in this repository can do it, and until it is done renovate.json5 is a file no bot reads.