Skip to content

Wave 3 and the 0.2.0 release commit: two security fixes, fail-closed trust defaults, API tracking, pinned runners - #688

Merged
IanFrelinger merged 8 commits into
masterfrom
integrate/wave-3
Oct 1, 2026
Merged

IanFrelinger merged 8 commits into
masterfrom
integrate/wave-3

Conversation

@IanFrelinger

@IanFrelinger IanFrelinger commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Eight commits, meant to be rebase-merged so each lands on master on its own, and so that the
tip is the 0.2.0 release commit. After merge: master's push readiness run, then 0.2.0-rc2 from that
commit (runbook step 8), then the readiness verdict for it (step 9), then the v0.2.0 tag.

Five fixes were built in parallel, each in its own worktree. Each went implement → adversarial verifier →
repair → final verifier that could block only on an unaddressed finding, a fail-open defect or a
day-one red. All five ended LAND with nothing blocking; every first verifier found 2–4 must-fix
items, and every one was closed or declined with evidence. Every behavioural change was mutation-checked.

Commit What Why it is in 0.2.0
ci(runners) Every job of the five required checks pinned to the image its -latest label resolved to on 2026-09-30 (from each job's "Set up job" log), and verify-required-runner-pins.py in required shell-lint refuses a floating label ubuntu-latest becomes Ubuntu 26.04 from 2026-10-19 (shellcheck 0.11, Python 3.14)
fix(fleet-host) Swagger served only in Development or with Ashlar__Api__EnableSwagger=true, as on Ashlar.API Security: it was served unauthenticated in Production
fix(security) One PathContainment helper; OutputPathSandboxed no longer accepts <root>-evil as inside <root>; a per-file ratchet forbids hand-rolled containment Security: a live sibling-prefix escape, reachable through AgentExecutorAdapter
fix(api) Brick.Contracts' 18 generative-profile port types (public since 0.1.0) recorded in PublicAPI.Shipped.txt; a convention bans public-API-tracking suppressions and requires every deprecation disable to name its symbol The stable-tier compatibility promise was false for those types
fix(trust) Data-sensitivity defaults fail closed: an omitted clearance means the floor; rag index requires --sensitivity; a lower re-label is refused; a filterless sanitizer blocks; a missing exfiltration policy derives from the agent's level rag search returned the whole corpus; re-indexing silently downgraded
test(uat) Tier 9.2 harvests [Experimental] types from product code only, with a floor of 60 (82 today) Its scan read a test file this series added and reported the [Obsolete] AshlarSdkBuilder as a leaked experimental type; nothing leaked. Reproduced, fixed, and mutation-checked both ways (a real leak still fails; an empty scan fails the floor)
docs(records) 12 records the five made false, plus their changelog entries —
release: Runbook step 7 (promote 6 PathContainment lines to Shipped), [Unreleased] folded into [0.2.0] - 2026-10-01, one contradiction in 0.2.0's notes corrected The release commit

User-visible changes (fail-closed)

  • rag search without --max-sensitivity searches at Public (it returned everything); the output states the clearance applied.
  • rag index refuses without --sensitivity (it stored Public); re-indexing at a lower label is refused — remove first.
  • ashlar improve with trust enabled now actually blocks/redacts PII (the proxy had no filter).
  • An agent config with no ExfiltrationPolicy section gets the restrictions its level implies. Disclosed loosening: for such an agent, the derived MaxAllowedLevel rises from an accidental Public default to its MaxDataSensitivity, which bounds machine-spawned children — matching the validator's own fallback.
  • No migration: the stores the shipped hosts use are in-memory per process.

Verification of the combined tree

  • In the devtest container, run as CI runs them: cert-gate 1593/1593 (0 skipped; the skip guard matches its baseline) and build-core built clean.
  • run-repo-gates.sh 23/23; knowledge graph current (4,495 facts).
  • Conflicts on integration were only the generated graph (regenerated) and two new rows in ci/cert-gate-assertions.md (both kept).

[skip-prod-style] - rationale

The testing-strategy gate flags this PR because two files under src/Ashlar.Hosting/ changed. Neither changes production wiring:

  • Meai/MeaiVectorDataRagAdapter.cs - the fail-closed default clearance (an omitted clearance now means the floor). That is adapter behaviour; its DI registration in the kernel phases is unchanged, and its own tests were updated in this PR.
  • Sdk/Builders/HostAshlarSdkBuilder.cs - one dead #pragma warning disable CS0618 line deleted. No behaviour change.

Measured rather than argued: on this tree's readiness run (36796629589) the ProdStyle and virtual-host suites all ran and passed on Linux, macOS and Windows - ProdStyle 39, KernelPhaseResolution 27, HostingE2ESmoke 30, HostingDeploymentProfile 81, and the changed adapter's tests 9; 0 failed. The DI-level change this series does make (ashlar improve registering the trust services) is pinned by the new ImproveCommandTrustWiringTests.

Known residue, not in this PR

bg proposals apply writes Path.Combine(repoRoot, p.TargetPath) with no containment check (a task chip was raised). Fleet.Host's /api/support/diagnostics 500s with a valid key. Swagger still turns on under ASPNETCORE_ENVIRONMENT=Testing in all three images — now documented. Two pinned follow-ups in the containment ratchet (AshlarEndpoints.cs, ProjectVerifier.cs).

🤖 Generated with Claude Code

t and others added 8 commits September 30, 2026 20:11
…ns today

Every job behind master's five required checks ran on a floating label.
From 2026-10-19 GitHub starts moving ubuntu-latest from Ubuntu 24.04 to
Ubuntu 26.04 (actions/runner-images#14748, rollout to 2026-11-19), and
with it the tools under those checks: shellcheck 0.9.0 -> 0.11.0 (what
shell-lint runs), Python 3.12.3 -> 3.14.4 (every repo gate), Docker
Compose 2.38.2 -> 5.1.3 (image readmes ubuntu24/20260927.320 vs
Ubuntu2604). None of that would be a commit here, and for the month of
the rollout two runs of the same commit could land on different images.
A required check that goes red that way blocks every pull request
(enforce_admins=true) with a diff that explains nothing.

The pins are behaviour-neutral: each equals what its -latest label
resolved to on 2026-09-30, read from the "Set up job" step of successful
runs (gh run view --job <id> --log):
  ubuntu-latest  -> ubuntu-24.04
      "Image: ubuntu-24.04", Ubuntu 24.04.5 LTS, builds 20260920.314.1 and
      20260927.320.1: cert-gate run 36775242663, build-core 36775242669,
      shell-lint 36767106261, lychee 36775242741, and every Linux,
      container, docker and summary readiness job of runs 36713219284
      (push, all lanes) and 36767106378
  macos-latest   -> macos-26
      "Image: macos-26-arm64" 20260907.0351.1 (macOS 26.6.2), both
      readiness runs
  windows-latest -> windows-2025-vs2026
      "Image: windows-2025-vs2026" 20260925.250.1 (Windows Server 2025),
      both readiness runs
The runner-images README (a99056ad72b0) maps each pinned label to that
same image. It lists windows-2025 for it too, but that label moved from
the VS2022 image to the VS2026 one in June 2026 (actions/runner-images
#14017, rolled out 2026-06-08 to 2026-06-15; #14004 saw jobs moved from
2026-05-05; the README only caught up on 2026-07-06 in 69bb04343d98), so
the pin names the Visual Studio image as well.

Prevention: scripts/ci/verify-required-runner-pins.py, discovered by
run-repo-gates in required shell-lint. It judges the runs-on of EVERY job
in the five required workflows (Readiness summary needs all the lanes),
resolving ${{ matrix.os }} to each matrix value, and a runner-group
mapping by its labels:, and accepts only a whole label shaped
ubuntu-NN.NN[-arm], macos-NN[-intel|-large|-xlarge] or
windows-NNNN-vsNNNN. It refuses any -latest in any case, windows-NNNN
alone, a label that only starts like a pinned one (ubuntu-24.04-16core,
the image name macos-26-arm64), an expression it cannot evaluate
(including a matrix or an include: built from one), an empty runner
list, a job with no runs-on, a reusable-workflow job and any unknown
label. The only exception is a reasoned `# runner-pin: allow <reason>`
YAML comment on the label's own line. PyYAML keeps no comments, so the
check finds each line's comment by elimination against the YAML
scanner's scalar spans: the same text inside a quoted value or a run:
script is data, excuses nothing and is refused, as are stale, stray,
malformed and reasonless comments. It fails closed when a required
workflow or its required job is missing, and run bare it needs at least
10 judged labels and an ubuntu, a macos and a windows one.
tests/scripts/required-runner-pins.test.sh has 50 assertions, among them
a clean-fixture control, a control that a real comment after a quoted
'#' is still honoured, and a real-tree control that each of the five
files and the macOS and Windows lanes were reached. 23 mutations (two
workflow reverts, 21 checker faults, one per refusal branch) were each
observed red on this commit.

The three docs that cite these labels describe environment-setup-gate-v1
and production-readiness-gate-v1, which are advisory and still float.
They now say that, and what the labels resolved to, instead of claiming
a pin those workflows do not have.

What this does NOT do: pin any other workflow (a later workstream owns
them); pin the image build (GitHub pins only the family, so weekly
rebuilds still arrive); pin what jobs install themselves (setup-dotnet
10.0.x, the apt shellcheck fallback) or container tags
(mcr.microsoft.com/dotnet/sdk:10.0); prove the pinned labels in a run.
The first CI run of this change is that proof - every readiness lane runs
because the readiness workflow is in its own path list.

Follow-up: trial ubuntu-26.04 on a branch (the check accepts it as a
pinned label) and fix what shellcheck 0.11 and Python 3.14 report there,
then move the pin as its own reviewed change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d in

Fleet.Host's pipeline was copied from Ashlar.API without the API's Swagger
gate, so it called UseSwagger/UseSwaggerUI unconditionally. It now uses the
API's gate with the same semantics: on in Development, otherwise only when
Ashlar:Api:EnableSwagger (Ashlar__Api__EnableSwagger) is true, and an
explicit false turns it off in Development too. Turning it on outside
Development logs the same Ashlar.Security notice the API logs. The shipped
appsettings.json now carries "Api": { "EnableSwagger": null }, as the API's
does, so the setting is visible there.

What an attacker could reach before. I published the pre-fix tree
(f0fd3fd) the way .docker/Dockerfile.fleet-host does and started it the
way the image does, with no ASPNETCORE_ENVIRONMENT, so it ran as
Production. Anyone who could reach its HTTP port got, with no credential:
- GET /swagger/v1/swagger.json: 200, the OpenAPI document (139119 bytes).
- GET /swagger/v1/swagger.yaml: 200, the same document as YAML (93765
  bytes). Swashbuckle 6.5.0 serves both formats from one route, and
  /swagger/v1/swagger.yml answered 200 with the JSON too.
- GET /swagger/index.html: 200, the Swagger UI page, and 200 for the
  stock Swashbuckle UI files it loads (index.js, swagger-ui.css,
  swagger-ui-bundle.js, swagger-ui-standalone-preset.js,
  oauth2-redirect.html, favicon-32x32.png).
- GET /swagger and /swagger/: 301 to /swagger/index.html. These redirect
  to the UI; they are not pages of their own.
No auth setting closed it: AshlarApiKeyAuthMiddleware and
MeshSecurityMiddleware both pass any path outside /api through before they
look at AuthorizationMode or AuthorizationScope. The document described 68
paths and 73 operations (38 GET, 32 POST, 1 PUT, 1 PATCH, 1 DELETE) with
112 schemas. That includes the fleet control routes: node register, admit,
revoke and drain; mesh task schedule, retry, lease and migrate; and
knowledge export and import.

What it did not give them. The document holds no data, configuration values
or credentials. Its top-level keys are openapi, info, paths and
components.schemas only. It grants no access either. Every route it lists
enforced the same auth whether or not Swagger was served, and the UI's
"Try it out" sends ordinary requests that the same middleware judges. So the
exposure was reconnaissance: a complete, typed map of the host's attack
surface, including which routes the shipped MutatingApi scope leaves
anonymous for GET. docs/DEPLOYMENT.md already said production images ship
with Swagger off, which was false for this image until now and is true now,
so the doc needs no edit.

After the fix, the published artifact (a2b84cea, whose product code this
commit keeps) answers 404 on the JSON, the YAML, /swagger, /swagger/ and
/swagger/index.html in its default Production environment and in Staging
(measured).

One environment still serves it, and this change does not close it. The
ProjectReference to Ashlar.API copies Ashlar.API's appsettings.Development.json
and appsettings.Testing.json into Fleet.Host's build and publish output,
byte-identical to the API's; Fleet.Host's own appsettings.json wins for the
base file. appsettings.Testing.json sets Ashlar:Api:EnableSwagger=true, so
the fleet-host image started with ASPNETCORE_ENVIRONMENT=Testing serves the
JSON, the YAML and the UI (measured 200 on the published a2b84cea
artifact, whose product code this commit keeps). The same file also
sets AwsSnsSkipSignatureVerification=true and Logging Default=Warning, and
the Warning level hides the Ashlar.Security notice. No deployment sets
Testing. Keeping Ashlar.API's appsettings files out of this host's output is
a build change this commit does not make; the tests pin the exception so
that it cannot grow quietly.

Tests. FleetHostSwaggerExposureTests starts the host through the project's
existing WebApplicationFactory pattern. Its client does not follow
redirects, so a served bare /swagger shows up as the 301 it is.
- Production with the shipped config, Staging, Production with an explicit
  false, and Development with an explicit false: the JSON, the YAML, the
  UI page and the bare /swagger each return 404, and no Ashlar.Security
  notice is logged. A failure lists every path that answered.
- Production and Staging started from Fleet.Host's own build output as
  content root, the way the image runs from its publish directory: the
  same 404s. The factory's default content root is the source directory,
  which never sees the files inherited from Ashlar.API; the build output
  does. The case checks that the host really used that content root, and
  that the directory's appsettings.json is identical to the host's own. The
  test assembly's own output directory would not do: there Ashlar.API's
  base appsettings.json overwrites Fleet.Host's (measured).
- An inventory of the build output's appsettings.*.json: the only
  environment whose effective EnableSwagger is true is Testing. Adding
  another fails it, and so does removing Testing's, so either change is
  reviewed.
- Development, and Production with the opt-in: the JSON and the YAML are
  this host's document, the UI is text/html, the bare /swagger is a 301 to
  swagger/index.html, and the Ashlar.Security notice is logged once in
  Production and never in Development.
Each hosted case first asserts /health is 200 on the same host, so a 404
cannot come from a host that failed to start.

Mutation-checked in the devtest container (net10.0), one mutation per
commit in a throwaway clone, whole suite (16) each time:
- The pre-fix Program.cs: 7 fail. The six not-served cases each report
  "swagger.json answered 200; swagger.yaml answered 200; index.html
  answered 200; /swagger answered 301", and Production+true finds no
  Ashlar.Security notice.
- Ignoring the config (IsDevelopment only): Development+false and
  Production+true fail (2).
- GetValue<bool> || IsDevelopment: Development+false fails (1).
- "?? !IsProduction()": both Staging cases fail (2).
- Shipping "EnableSwagger": true: 5 fail, the four Production/Staging
  cases and the inventory.
- UseSwaggerUI outside the gate: the six not-served cases fail on
  "index.html answered 200; /swagger answered 301".
- An unconditional YAML-only UseSwagger: the six not-served cases fail on
  "swagger.yaml answered 200" alone, so the YAML path is asserted in its
  own right.
- The verifier's M8, application/src/Ashlar.API/appsettings.Production.json
  with EnableSwagger=true: the build-output Production case and the
  inventory fail (2). The source-directory Production case stays green,
  which is why the build-output case exists.
- The verifier's M9, deleting the Ashlar.Security log block:
  Production+true fails, expected 1 notice, found 0.
- Logging the notice in Development too: Development fails, expected 0,
  found 1.
- Removing EnableSwagger from Ashlar.API's appsettings.Testing.json: the
  inventory fails (found empty), the other direction of the pin.
- Using the test assembly's own output directory as the image stand-in:
  the three build-output cases fail on the appsettings.json identity check
  (it holds Ashlar.API's file there).
- Dropping UseContentRoot: both build-output cases fail on the content
  root check.
Before and after the batch, the unmutated tree passes 16/16.

The private-license gate is not added here. Program.cs records why: this
host registers neither IPrivateLicenseValidator nor the Ashlar:PrivateLicense
options, so EnforceLicense has no effect on it.

What this does NOT fix:
- ASPNETCORE_ENVIRONMENT=Testing on the fleet-host image, described above.
- The gate now exists in two copies, in Ashlar.API and in Fleet.Host. A
  shared helper would stop them drifting apart again. It needs an
  application/ edit that this change does not make.
- When Swagger is opted in, it is still served without auth, exactly as on
  Ashlar.API.
- While measuring I found another fault. GET /api/support/diagnostics on
  Fleet.Host throws "No service for type IPrivateLicenseValidator" when
  called with a valid API key (401 without one), which is a 500 on a real
  server. It is untouched here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed's sibling-prefix escape

WHAT THE ESCAPE ALLOWED. OutputPathSandboxed approved a tool call when
Path.GetFullPath(output).StartsWith(Path.GetFullPath(OutputRoot), OrdinalIgnoreCase)
held, with no directory separator after the root. Any output whose full path merely
BEGINS WITH the root's characters therefore passed: with OutputRoot /srv/app/out it
approved /srv/app/out-evil/x, /srv/app/outx and the file /srv/app/out.dll - every entry in
the root's PARENT directory whose name extends the root's last segment, however spelled
(/srv/app/out/../out-evil/x reaches the same place). It did not admit an arbitrary '..'
escape (GetFullPath collapses those before the comparison) or an unrelated directory. On
Linux the ignore-case comparison additionally admitted a case variant of the root
(/srv/App/out/x), which there names a different directory; PathAllowlist's absolute-path
leg had that case half of the defect too. The escape is live:
AgentExecutorAdapter (the IAgentExecutor behind RunAgentHandler) composes OutputPathSandboxed
with AllowAllPolicy and sets OutputRoot to <cwd>/out, so a model-chosen
assembly.decompile call with output "<cwd>/out-evil" (or "outx", relative) passed the policy
engine, and that tool creates the directory and writes <name>.decompiled.cs into it: a
write into the process working directory beside the sandbox, not under it.
Proven first against unmodified code (Linux, net8.0): 8 of 39 PathContainmentTests cases
failed - 7 OutputPathSandboxed rows (6 escapes admitted: sibling directory, file and bare
name, dot-dot-to-sibling, a backslash-dot-dot suffix, the Linux case variant; plus a root
spelled with a trailing separator falsely refused) and 1 PathAllowlist row (the Linux case
variant) - and the new Kernel sibling fact failed.

WHY A HELPER. There were ten hand-written containment checks in production code with five
semantics: no separator (OutputPathSandboxed); strictly-inside ordinal (MediatedWritePath);
root-inclusive ignore-case everywhere (WorkspacePathPolicy, SnapshotManagedFileSet,
PathAllowlist, RepoFsListTool, RepoFsReadTool); root-inclusive ordinal (ProjectVerifier);
root-inclusive and platform-dependent case (ToolSandbox: fold on Windows and macOS;
AshlarEndpoints: fold on Windows only). Ashlar.Abstractions.Paths.PathContainment is now
the one definition: GetFullPath on both arguments, refuse anything unresolvable or still
carrying a '.'/'..' segment (only a Windows \\?\ path can), drop trailing separators (never
below the filesystem root), then equal-or-starts-with-root-PLUS-SEPARATOR. IsWithin counts
the root itself; IsStrictlyWithin does not. Case follows the platform's default file
system (OrdinalIgnoreCase on Windows and macOS, Ordinal elsewhere) unless the caller passes
Ordinal/OrdinalIgnoreCase; culture comparisons throw. It lives in Ashlar.Abstractions
because that is the lowest project every copy can reach without a new ProjectReference
(Ashlar.Policies references only Abstractions), written to the netstandard2.0 surface; its
five members are in PublicAPI.Unshipped.txt.

Converted, with each site's semantics kept except where the old one was the defect:
OutputPathSandboxed (separator; Linux now case-sensitive), PathAllowlist absolute leg
(Linux now case-sensitive), WorkspacePathPolicy (Linux case-sensitive; a root configured
with a trailing separator now contains itself), SnapshotManagedFileSet, ToolSandbox
(identical semantics; its private copy and PathComparison are gone), RepoFsListTool and
RepoFsReadTool (their own TryResolveRelative copies have no caller at all - both tools
resolve through ToolSandbox.TryResolvePath - so converting them changes nothing reachable),
MediatedWritePath (IsStrictlyWithin, Ordinal on every platform, both the containment leg
and the reparse-walk bound; a target resolving to the root WITH a trailing separator,
"./" or "a/../", is now refused as an escape like "." already was, instead of as an unsafe
shape), and TempDirTestBase's unused temp-dir assertion (same missing-separator defect).
OutputPathSandboxed also refuses a non-string "output" (5, true, an array, an object)
instead of throwing InvalidOperationException out of Approve: GetString moved inside the
existing try. That throw predates this change.

PREVENTION, BEHAVIOUR FIRST. PathContainmentTests (cert-gate) runs a 20-row absolute table
against the helper, OutputPathSandboxed and PathAllowlist, and a 10-row RELATIVE table
against the sites that resolve a model-supplied path against a sandbox root: ToolSandbox's
read resolver, repo.fs.read and repo.fs.list on every row, and its write resolver on every
escape row plus an ordinary-child control. The relative table builds <stem>/repo with real
siblings <stem>/repo-evil/x.cs, <stem>/repox and <stem>/repo.dll. A separator-less helper
put back into ToolSandbox (here.StartsWith(there, PathContainment.PlatformComparison))
turns 15 of its rows red, and repo.fs.read is seen returning the sibling's contents; before
this table that reintroduction left every suite green. The write-resolver rows stay green
under it because MediatedWritePath checks containment again, and they pin that it does.

PathContainmentConventionTests (cert-gate) is the ratchet: every production .cs file's
count of hand-rolled checks must EQUAL its pin (two remain: AshlarEndpoints.cs 2,
ProjectVerifier.cs 1), so a new copy and a stale pin both fail. A .StartsWith( counts when
its non-literal first argument or its receiver has full-path provenance, when it compares
with PlatformComparison, or when its interpolated literal argument embeds such a path. An
expression has provenance when it spells GetFullPath(, a directory separator, or
new FileInfo(/new DirectoryInfo(, or names an identifier that (1) is assigned from such an
expression, (2) is assigned ONE path-building step from one (Path.Combine/Join, a
concatenated separator literal, an interpolated string with a separator in it, a plain or
trimmed alias; a Substring is relative again and drops it), or (3) is a parameter of a
method declared in the same file that a call in the same file passes such an argument.
Comments and literal contents are blanked first; interpolation holes are kept. Measured with
a Python port and confirmed by the C# scan: at the base exactly the 13 calls in the ten
copies match out of 126, and the new rules add no match anywhere in the repository,
including test projects (3,149 tracked files, 227 calls). With the eight converted files put back,
the C# ratchet lists exactly those eight (10 calls). Full transitivity was measured and
rejected: it flags MediatedWritePath's relative allowlist and a proposal target-prefix
filter. 38 controls pin the classifier (19 must count, 16 must not, 3 known misses pinned
at zero). Test projects are now skipped by their Microsoft.NET.Test.Sdk reference rather
than by a directory name containing "Tests", which had skipped the production use case
src/Ashlar.Core.Application/Testing/UseCases/RunTests; products/ and tools/ are now scanned
(8 more files, no new hit).

WHAT THIS DOES NOT FIX. The helper is lexical: it does not follow symlinks or junctions (a
fact pins that; MediatedWritePath keeps its own reparse-point probes), does not model a
case-sensitive volume on Windows/macOS, and does not unify 8.3 names, \\?\ against plain
spellings, mapped drives or bind mounts. The scan's provenance is by identifier NAME within
one file, not by type, scope or data flow across files, so it cannot see a helper whose
callers live in another file (static bool Under(string a, string b) =>
a.StartsWith(b, StringComparison.Ordinal)), a root that comes back from a method call
(candidate.StartsWith(root + "/") with root = ResolveRoot(...), which is textually the
relative-allowlist idiom it deliberately ignores), the FullName of a FileInfo the code
receives rather than constructs, or IndexOf(root) == 0, string.Compare and Uri.IsBaseOf.
The first three are pinned as known-miss controls. The first draft of this change said the
scan's only blind spot was a two-parameter helper with no path construction beside it; that
was wrong, because provenance was by name, and a separator-less helper in a file full of
GetFullPath calls went unseen. The tables, not the scan, are the proof. Not converted:
application/src/Ashlar.API/Endpoints/AshlarEndpoints.cs (would make this src/ change span
application/, which layer-boundary refuses without [coordinated-integration]; already
separator-aware) and src/Ashlar.Manifest/ProjectVerifier.cs (Ashlar.Manifest references no
project; already separator-aware). OutputPathSandboxed still resolves a RELATIVE output
against the process working directory rather than OutputRoot, and still guards only the
"output" argument. WorkspacePathPolicy still has no direct test (its only constructor reads
WORKSPACE_ROOT, a process-global variable a cert-gate test cannot set without racing
parallel tests); the helper table covers its logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing and deprecation suppressions

WHY. Ashlar.Brick.Contracts is a stable-tier package, and the mechanism
docs/SdkCompatibilityPolicy.md names for its promise is the public-API analyzer:
RS0016 fails the build when a public symbol is in neither PublicAPI file. The five
files under src/Ashlar.Brick.Contracts/Authoring/Ports/ opened with a file-wide
`#pragma warning disable RS0016` that was never restored ("Profile ports ship via
Unshipped incrementally; suppress until batched" - the batch never came). None of
their lines was in either PublicAPI file, so 18 public types - AgentProfile,
AgentProfileCapabilities, GenerationTunables, BrickConstraintManifest,
GenerationRequest, GeneratedArtifact, AcceptanceDecision, DeploymentSmokeResult,
AcceptanceContext, AcceptanceResult, IAcceptanceEvaluator,
DefaultAcceptanceEvaluator, IAcceptanceGatedDeploymentTarget, IArtifactDrafter,
IDeterministicDrafter, ISandboxProvider, IDeploymentTarget, DeploymentApplyResult
(namespace Ashlar.Core.Domain.Bricks.Ports) - were promised by nothing, and any
change to them built green. With 0.2.0 about to publish, the promise in the policy
was false for them.

SHIPPED, NOT UNSHIPPED - the evidence.
- `git ls-tree v0.1.0|v0.1.1|v0.1.2 -- src/Ashlar.Brick.Contracts/Authoring/Ports/`
  lists all five files at every tag, and both `git diff v0.1.0 v0.1.2 -- .../Ports/`
  and `git diff v0.1.2 f0fd3fd -- src/Ashlar.Brick.Contracts/` are empty. Every one
  of these symbols was public, unchanged, in three tagged releases the policy
  records as published. That is a promise already made: Shipped.txt.
- Nothing in this surface is new since 0.1.2, so nothing goes in Unshipped.txt, and
  that is also what keeps the 0.2.0 tag commit right. The 0.2.0 cut is already on
  master (#628 set VERSION=0.2.0; #678 "release: cut 0.2.0" set
  ci/published-version=0.2.0 and recorded that PublicAPI promotion was a no-op),
  and RELEASE_RUNBOOK step 7's promotion has already left every Unshipped.txt at
  the bare header - no PublicAPI file has changed since. A line put in
  Unshipped.txt now would reach the 0.2.0 tag unpromoted unless someone re-ran
  step 7, and the tag is what turns a line into a promise; so a symbol genuinely
  new since 0.1.2 would belong in Shipped.txt as well. There is none here.
- The 147 lines are the analyzer's own, not hand-written: read from the RS0016
  diagnostics' APIName property (SARIF via -p:ErrorLog), 144 names identical for
  netstandard2.0, net8.0 and net10.0, plus the three positional-record primary
  constructors the analyzer only reports once their types are declared. `dotnet
  format analyzers --diagnostics RS0016` applied no fix to this multi-targeted
  project (measured; also single-TFM), so it was not used. The lines carry nullable
  annotations (`string!`) as the analyzer emits them and as the Abstractions,
  Client, Sdk and Authoring files already do; the older oblivious Brick.Contracts
  lines are left alone. Shipped.txt: 465 -> 612 lines.
- Not [Experimental]: demoting a shipped stable API is a breaking change under the
  policy, and it would not be true - these are in use as a stable contract.

THE DEAD CS0618 PRAGMA. src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs:7
disabled CS0618 because "AshlarSdkBuilder is an obsolete type forwarder in this
file". #223 (2026-07-04) moved the forwarder into AshlarSdkBuilder.cs and the pragma
stayed, suppressing nothing for 88 days while reading as a reason - and silencing
in advance the next obsolete call anyone wrote into the file. Deleted. Measured:
Ashlar.Hosting builds with 0 warnings / 0 errors without it on net8.0 and net10.0.

THE PREVENTION: DiagnosticSuppressionConventionTests (cert-gate namespace, row in
ci/cert-gate-assertions.md).
- Rule 1: nothing switches public-API tracking off. No source anywhere disables
  RS0016/RS0017/RS0026/RS0027 by pragma or [SuppressMessage]/
  [UnconditionalSuppressMessage], and no source has a bare `#pragma warning
  disable`; no MSBuild file lists those ids in NoWarn/WarningsNotAsErrors; no
  .editorconfig/.globalconfig lowers them to none/silent/suggestion or sets
  `dotnet_public_api_analyzer.skip_namespaces`, and none that applies to an
  analyzer project lowers the ApiDesign category or every analyzer; no analyzer
  project or Directory.Build.* above one sets RunAnalyzers(DuringBuild) false or
  removes an API file from AdditionalFiles; and each of the 6 projects referencing
  the analyzer wires both API files as AdditionalFiles. The source scan is
  tree-wide on purpose: a pragma acts on its own file, files are linked into these
  projects from outside them, and an RS0016 pragma anywhere else is dead by
  definition.
- Rule 2: a `#pragma warning disable` of CS0618 or ASHLAREXP001 - in every spelling
  the compiler accepts, which for CS0618 includes the bare number: Roslyn maps a
  numeric pragma id (and a numeric /nowarn id) to "CS" + the number padded to four
  digits, so `618` and `0618` ARE CS0618 - must name, as an exact identifier, an
  in-tree [Obsolete] (CS0618 - not error: true, no DiagnosticId) or
  [Experimental(ASHLAREXP001)] symbol; at least one named symbol must be used in
  the code the pragma governs OUTSIDE a declaration carrying the same attribute
  (the compiler reports neither diagnostic in an obsolete/experimental context);
  and the comment must give a reason of at least three words. Tree-wide, because
  the defect it exists for was in Ashlar.Hosting, which does not use the API
  analyzer.
- Rule 2 also covers the disables that cannot carry such a comment: a
  [SuppressMessage]/[UnconditionalSuppressMessage] naming either id; a
  NoWarn/WarningsNotAsErrors element or attribute listing either id (any
  spelling) in any MSBuild file; and a `dotnet_diagnostic.<id>.severity` of
  none/silent/suggestion in any analyzer config. A project-wide disable names no
  symbol by construction, so every one must be on ReviewedProjectWideDisables,
  which records why. Two exist and both are deliberate, documented opt-ins:
  Directory.Build.targets:32 (test projects exercise the experimental surface;
  the diagnostic exists for external consumers) and the FirstFlight spike's
  csproj:14 (the spike is a consumer, outside Ashlar.sln and every gate).
- Eight existing pragmas fail Rule 2 in files outside this change; they are
  grandfathered by exact text, each with what clears it. Both lists fail in both
  directions (a new violation, or an entry that no longer matches), so each can
  only change by a reviewed diff. Compiler ground truth, measured by deleting each
  pragma and building on f0fd3fd: both Ashlar.Sdk/Legacy CS0618 pragmas are DEAD
  (Ashlar.Sdk builds clean - every use sits inside an [Obsolete] context); the
  test one is live but uncommented; all six ASHLAREXP001 pragmas are live, and
  five of them name no symbol ("touch-set", "lineage"). ObjectiveDocument.cs
  already complies and is asserted to pass.
- Stated floors, not derived ones (2500 C# files, 100 MSBuild files, 6 analyzer
  projects, 40 experimental names, 1 obsolete name, 10 disable pragmas; measured
  3149, 133, 6, 65, 2, 15). The obsolete floor is 1, under the measured 2, so that
  retiring one forwarder - the normal end of a deprecation - is not reported as a
  broken parser; the real-tree anchors (AshlarSdkBuilder, TouchSet, ...) say in
  their failure messages that a removed or graduated symbol means "move the
  anchor", not "the parser is broken". Controls over in-memory text pin every
  banned spelling (including 618/0618 and a numeric restore), immunity to
  comments and literals (including raw and verbatim strings), the obsolete-context
  rule, CS0619/DiagnosticId exclusion, the project-wide and attribute forms and
  what they must not catch (CS8618, 6180, WarningsAsErrors, a commented-out
  NoWarn), and the shipped HostAshlarSdkBuilder.cs verbatim as a dead pragma in
  all its spellings.

VERIFIED in the devtest container on the committed tree:
- Builds, 0 warnings / 0 errors: Brick.Contracts on netstandard2.0, net8.0 and
  net10.0; Hosting on net8.0 and net10.0; Tests.Infrastructure on net8.0 and
  net10.0. Ashlar.LocalDevCore.slnf (what build-core builds) restores and builds,
  0 errors. The class passes 96/96 on net8.0 and on net10.0 (TRX executed=96).
  scripts/run-cert-gate.sh exits 0: total=1410 executed=1410
  passed=1410 failed=0 (the class's 96 among them), zero-test guard 1410 against
  expected>=1405 from --list-tests, skip guard 0 skipped, matching
  scripts/cert-gate-skipped.baseline.
- Mutations, each proven applied by the original string's count moving, each
  restored to an empty `git status --porcelain`, then rerun green:
  * the shipped dead pragma back at HostAshlarSdkBuilder.cs:7 as
    `#pragma warning disable 618 // AshlarSdkBuilder is ...` -> the Rule 2 fact
    fails (95/96): "HostAshlarSdkBuilder.cs:7: '#pragma warning disable 618 ...'
    - CS0618: is dead". Review measured the first round passing 63/63 on it.
  * a new `new AshlarSdkBuilder(null!)` in Ashlar.Hosting under a bare
    `#pragma warning disable 618` -> Hosting builds exit 0 (the compiler IS
    silenced) and the Rule 2 fact fails: "ApivProbe.cs:1: '#pragma warning
    disable 618' - CS0618: carries no comment". Without the pragma, Hosting
    fails with CS0618 on net8.0 and net10.0 (2 errors).
  * `<NoWarn>$(NoWarn);CS0618</NoWarn>` in Ashlar.Hosting.csproj with that use
    present -> Hosting builds exit 0 and the project-wide fact fails naming
    "Ashlar.Hosting.csproj:9"; spelled `618`, it fails the same way.
  * `dotnet_diagnostic.CS0618.severity = none` in the root .editorconfig -> the
    project-wide fact fails naming ".editorconfig:188".
  * FirstFlight's reviewed NoWarn deleted -> the project-wide fact fails: "is on
    the reviewed list but no longer found".
  * an assembly-level [SuppressMessage("Usage", "CS0618:...")] -> the Rule 2 fact
    fails naming it.
  * numeric-id normalisation disabled -> exactly the 9 numeric-spelling controls
    fail (87/96); the project-wide element scan disabled -> the real-tree
    project-wide fact fails with BOTH reviewed entries stale, plus the 5 element
    controls (90/96).
  * from the first round, still standing: RS0016 pragma back on AgentProfile.cs:1
    -> Rule 1 fails naming it; a new public property on GenerationTunables ->
    Brick.Contracts fails RS0016 for Extra.get/Extra.init on all three TFMs, and
    the SAME change with the old pragma restored builds 0/0 - what the
    suppression hid; one Shipped.txt line removed -> RS0016 on all three TFMs;
    RS0016 in the project's NoWarn -> Rule 1 fails; a Ports-scoped
    skip_namespaces -> the property's RS0016 disappears (the option really
    untracks) and Rule 1 fails naming the line; the eight grandfathered pragmas
    rewritten as their notes say -> eight "no longer violates", and with the
    list emptied it passes; declaration parser broken -> the floor fires;
    obsolete-context rule removed -> exactly its control fails; literals not
    blanked -> six facts fail.
- scripts/ci/run-repo-gates.sh: all 21 gates pass. Knowledge graph regenerated
  (declared facts 4399 -> 4414) and verified current.

WHAT THIS DOES NOT FIX.
- The eight grandfathered pragmas (two to delete, six to name their symbols) and
  docs/SdkCompatibilityPolicy.md, which still quotes 465 Shipped lines for
  Brick.Contracts and does not list the Ports types - all outside this change.
- Rule 2 is lexical: it proves a disable names a real symbol that the governed code
  still mentions outside that symbol's own context, not that the compiler would
  report there (a lambda inside an obsolete member, nameof, a same-named symbol
  from another assembly all read as uses). An obsolete API declared outside the
  tree cannot be named and needs a reviewed grandfather entry. CS0612 (an
  [Obsolete] with no message) is a different id and is not policed.
- Neither rule evaluates MSBuild: conditions are ignored (a disable is flagged
  whatever its Condition), and a disable routed through a property of another
  name (`<NoWarn>$(MyList)</NoWarn>`) or passed on a command line (`-p:NoWarn=`,
  `-nowarn:` in a script or workflow) is not seen. TreatWarningsAsErrors,
  WarningLevel and the bulk `dotnet_analyzer_diagnostic.*` settings are not
  checked for Rule 2. Rule 1 follows no imports beyond the Directory.Build.*
  chain and does not treat an <auto-generated/> header as a suppression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r is missing

Every place the runtime met a MISSING sensitivity fact filled it in with the value that widened
access. This inverts each default, and the tests that pinned the open defaults as the contract.

A clearance and a label fail closed in OPPOSITE directions, and the old code applied one rule to
both, so it was open both ways:

- An omitted or unrecognised CALLER clearance is now the FLOOR (Public). MeaiVectorDataRagAdapter
  -- the IRAGService the shipped hosts resolve -- turned an omitted clearance into "TopSecret", so
  `rag search` without --max-sensitivity returned the whole corpus, and the rag:search audit then
  recorded caller_tier=TopSecret as if the caller had been cleared. TrustTierOrder ranked any unknown
  caller tier ("Unclassified", a typo) as TopSecret. The legacy InMemory/Sqlite stores applied no
  filter at all unless a registry was supplied AND the clearance resolved. The audit now records the
  clearance actually applied and why: caller_tier=<applied> caller_tier_basis=explicit|omitted|
  unrecognised.
- An unlabelled or unrecognised RECORD is now the MOST RESTRICTIVE level. TrustTierOrder ranked a
  blank tier as Public, ChunkRecord.TrustTier defaulted to "Public", the adapter indexed an omitted
  label as "Public", the legacy stores returned unmarked documents to everyone, and
  DataSensitivityMarker reported unmarked data as Public. "Most restrictive" is the registry's
  highest level (never below TopSecret), so a custom level above TopSecret is honoured. No label is
  invented: an unlabelled record is stored unlabelled and reported as null.
- `rag index` REFUSES without --sensitivity (and refuses an unknown one). Defaulting to the top was
  rejected: a knowledge base indexed that way is invisible to every search below the top clearance,
  so the omission would surface later as "search finds nothing" with no pointer to its cause. The
  library layer underneath still stores an omitted label as unlabelled (= top) for programmatic
  callers. `rag search` refuses an unknown --max-sensitivity instead of flooring it silently, and
  reports the clearance it searched at.
- Re-indexing an existing key at a LOWER label is refused, in VectorDataRagService and in both
  legacy stores (inside the write's transaction for Sqlite). The indexer keys on the full file
  path, so re-running an index with a lower label silently downgraded previously Secret content.
  Lowering a label is now a separate act: remove, then index. An unlabelled stored record counts as
  the top, so it cannot be relabelled downward by re-indexing either.
- DataExfiltrationPolicy holds an agent it cannot identify (no, blank or non-string agentId, or an
  id the registry does not hold) to the MOST restrictive policy instead of approving with "OK". Its
  scope is unchanged: it only ever refuses the exfiltration-class tool ids, so a local tool is still
  approved -- an unidentified agent is treated as the most restricted registered agent, not as
  "deny everything", which would also have stopped `ashlar self-extend`, whose snapshot agentId is
  the role name and is not a registered agent. A REGISTERED agent whose config or policy is null
  gets the most restrictive policy too (that line used to throw a NullReferenceException).
- CloudSanitizationProxy BLOCKS (and audits the block) when it has no content filter, instead of
  passing every prompt through unaudited. That was the state `ashlar improve` ran in with
  ASHLAR_TRUST_ENABLED=1: it registered the proxy by type, DI built it with every optional
  dependency null, and "trust enabled" wrapped the provider in a sanitizer that sanitized nothing.
  improve now takes the kernel's trust composition (AddTrustServices, provider registration skipped
  because improve builds that chain itself). A redaction is also audited once, not twice.
- BackgroundAgentConfigLoader's level-to-policy derivation could not fire: it waited for a blank
  MaxAllowedLevel, and an absent ExfiltrationPolicy section binds to defaults whose MaxAllowedLevel
  is "Public". A TopSecret agent with no section ran with every Block flag false; the only test of
  the derivation passed because it WROTE a section with a whitespace MaxAllowedLevel. The loader now
  binds agents one at a time and detects the section's absence. That test is replaced by ones that
  omit the section; the old trigger is kept (and pinned) so nothing that used to derive stops.
  Binding per agent keeps, element by element, the outcome the list bind gave -- measured on both
  for twelve element shapes: a value the binder cannot convert ("Enabled": "notabool") or a scalar
  where an agent belongs leaves THAT agent out and the rest load; an empty element ({} / null) is
  still refused with "Agent ID is required". The one difference is that a left-out agent is now
  logged as a warning naming its configuration path; the list bind dropped it silently.
  Side effect, disclosed: the derived policy's MaxAllowedLevel is the agent's MaxDataSensitivity,
  where an absent section used to leave the default "Public". AgentPolicyNarrowingValidator bounds
  a machine-spawned child's MaxAllowedLevel and AllowedDataSensitivityLevels by that value, so the
  child of a no-section parent at Internal or above may now carry levels up to the parent's own
  MaxDataSensitivity (before: refused above Public). For an Internal parent, which derives no Block
  flags, that is a pure loosening; nothing outside the validator enforces MaxAllowedLevel.
- RAGTool: the model's maxSensitivityLevelName can now only NARROW the agent's clearance from the
  snapshot, never raise it, and with no agent clearance the search runs at the floor. Latent (no
  production construction), but it was a prompt-injection route to TopSecret the day it is wired.
- AgentPolicyNarrowingValidator gets an isolating test for its level compare: the existing rejection
  test was refused on BlockExternalLLMs first, so deleting the compare left every test green.

Inverted pinning tests: DataSensitivityMarkerTests (unmarked -> Public, CanAccess unmarked -> true),
CloudSanitizationProxyTests (no filter -> allowed), DataExfiltrationPolicyTests (no agentId / unknown
agent -> allowed "OK"), BackgroundAgentConfigLoaderGapCoverageTests (the whitespace tripwire).
New pins: a registered agent with no policy is denied; a malformed or scalar agent element is left
out with a warning while its neighbour loads; an empty element is refused.
Legacy-store tests that indexed unmarked documents as incidental setup now label them Public, so
each still tests what its name says instead of passing on the new sensitivity filter.

Every behavioural change was mutation-checked in the devtest container: the mutation proven applied
(original text count 1 -> 0), the named tests observed red, the clone restored clean.

What this does NOT fix (later, larger steps): compartments, principals and no-write-down; the
exfiltration tool lists are still DENY-lists (an exfiltrating tool with an unlisted id passes); an
agent's own ModelProvider is not checked against its sensitivity; the taxonomy is still not
consulted by the sanitization proxy; peer:* targets, the router's cloud fallback, barrier-header
precedence, the AirGapped profile's model egress and the 24h cross-agent observation feed are
untouched; custom sensitivity levels are ranked by the legacy stores but not by the MEAI store's
fixed five-tier scale (there a custom record label ranks at the top and a custom caller clearance
at the floor -- closed, not correct); an ExfiltrationPolicy section that IS present but weaker than
the agent's MaxDataSensitivity implies is still honoured as written; the loader catches only the
binder's InvalidOperationException for a malformed agent (the list bind swallowed any exception
type; the bound config types have no custom setters, so a malformed value surfaces as that one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the five workstreams below refused to edit a file outside its
ownership and listed the text its change made false instead. This applies
those corrections in one place. Docs and comments only; no behaviour changes.

- docs/GapAnalysis.md: section 4 said improve's trust wiring was resolved, but its proxy had no filter until fail-closed; the test it recommended now exists.
- docs/ExecutionPlan.md: C.6 rows and section described the same wiring and listed its test as remaining; now Tested, with the test linked.
- docs/meai-migration-notes.md: the proxy now blocks when it has no filter, and it never consulted the taxonomy ("may further constrain" was false).
- docs/SdkCompatibilityPolicy.md: Brick.Contracts Shipped.txt is 612 lines, not 465 (147 Ports lines); Abstractions' Unshipped.txt now holds the six PathContainment lines; the 18 Ports types and the suppression convention are listed.
- docs/DEPLOYMENT.md: Fleet.Host now honours Ashlar__Api__EnableSwagger, and ASPNETCORE_ENVIRONMENT=Testing serves Swagger in all three images through Ashlar.API's appsettings.Testing.json.
- docs/Configuration.md: the EnableSwagger row now says Fleet.Host honours the key, and names the Testing exception.
- .docker/Dockerfile.fleet-host: the header said Fleet.Host targets net8.0 on an 8.0 runtime; the csproj targets net10.0 and the runtime stage is aspnet:10.0.
- shell-lint.yml and full-platform-readiness-gate.yml: two comments still named ubuntu-latest for jobs now pinned to ubuntu-24.04 (comment text only).
- CHANGELOG.md [Unreleased]: the wave's user-visible changes, including the disclosed MaxAllowedLevel loosening for agent configs with no ExfiltrationPolicy section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and refuses an empty scan

Tier 9.2 asserts no [Experimental] type appears in any PublicAPI.Shipped.txt.
It built its list of experimental types by harvesting class names from every
.cs file under src/ that contains the text "[Experimental(" - test projects
included. DiagnosticSuppressionConventionTests (added earlier in this series)
polices that attribute, so it contains the text five times, and it declares a
fixture class named AshlarSdkBuilder. The check therefore reported the real
AshlarSdkBuilder - which is [Obsolete], not [Experimental], and has been in
Ashlar.Sdk's PublicAPI.Shipped.txt since #499 - as an experimental type leaked
into the stable promise. Both UAT jobs went red on it. Nothing leaked.

- The harvest now skips any directory whose name contains "Tests".
- A floor: 82 experimental types are declared in product code today; fewer than
  60 means the scan lost its lens, and "none leaked" from an empty list is a
  pass that proves nothing, so it fails instead.

Observed, running only the 9.2 block against the tree:
- unfixed tree: FAIL "experimental types in the stable promise: AshlarSdkBuilder"
  - the CI failure, reproduced;
- fixed tree: PASS, 82 types;
- a REAL leak (Ashlar.Core.Application.Autonomy.TouchSet appended to a Shipped
  file): FAIL naming TouchSet;
- an empty scan (the file pattern changed to match nothing; anchor count 1 -> 0):
  FAIL "the scan found only 0 [Experimental] types (floor 60)";
- restored: porcelain empty, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hangelog

The release-commit steps of docs/RELEASE_RUNBOOK.md "Before you tag" that the
0.2.0 cut (#678) could not do, because the work they cover landed after it.

- Step 7, promote the public API. src/Ashlar.Abstractions/PublicAPI.Unshipped.txt
  carried six lines - the Ashlar.Abstractions.Paths.PathContainment helper added
  by the path-containment fix in this series. They move to PublicAPI.Shipped.txt
  (399 -> 405 lines), so every Unshipped.txt is back at the bare header and the
  next release's Unshipped diff shows only what is new after 0.2.0.
  docs/SdkCompatibilityPolicy.md said those six were "promised at the next tag";
  it now records that this commit promoted them.
- CHANGELOG: [Unreleased] - two waves of work since the cut (9 Changed, 2 Fixed,
  4 Security) - folds into [0.2.0], dated 2026-10-01, the day it is tagged.
  [Unreleased] is reset to "Nothing yet.".
- One statement in [0.2.0] became false by that fold: the Ashlar.Analyzers.Tests
  entry said the readiness routing gap was "deliberately left open here". The
  closure fix in this same release closed it. 0.2.0 is not published, so its notes
  are not yet history; the sentence now points at the closing entry instead.
- consumer-template/CONSUMING.md dated the 0.2.0 pin 2026-09-29, the cut date; it
  now carries the release date.

Steps 4-6 (version files, CONSUMING.md's trust rows, published-version docs) were
done on the cut and are unchanged. Step 8 (rc rehearsal) and step 9 (readiness
verdict) run against THIS commit after it reaches master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IanFrelinger
IanFrelinger merged commit 5bd7b89 into master Oct 1, 2026
36 of 37 checks passed
@IanFrelinger
IanFrelinger deleted the integrate/wave-3 branch October 1, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant