Map build delivery, and narrow the Idunn invariant to services

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-17 22:00:45 +02:00
commit 2c2b92579f
2 changed files with 547 additions and 2 deletions

542
docs/build-delivery-cut.md Normal file
View file

@ -0,0 +1,542 @@
# Build Delivery: Cut Map
Date: 2026-09-17
Status: Imagination pass, cut map. Nothing here has landed. Anchors are against
`codex/item-provenance` HEAD `6dc18607`. Ends are owned by
`docs/build-delivery-target.md`; this document owns the means.
Claims marked **(probe)** were run, not read off names. What was run:
- A real batchmode player build of the main tree, Unity closed, output to a
scratch directory outside the repo:
`Unity.exe -batchmode -quit -nographics -silent-crashes -projectPath F:\Projects\Aetheria -buildWindows64Player <scratch>\playerbuild\Aetheria.exe -logFile <scratch>\unity-player-build.log`.
No Editor script was added; `-buildWindows64Player` needs none. Exit code 0.
It dirtied one tracked file, `ProjectSettings/UnityConnectSettings.asset`,
which was restored to `6dc18607`; the other 16 modified files in the worktree
predate this pass. Build output and the zip were deleted afterwards.
- A headless run of the produced player:
`Aetheria.exe -batchmode -nographics -logFile <scratch>\player-run.log`, killed
at 25s.
- Reads of `gamecult-ops` inventory, runbooks, `nginx/`, and its deploy scripts;
Idunn `src/` and `docs/`; `gamecult-site`, `AetheriaLore` and `GameCult-Quartz`
configs and workflows; the `CultLib-semver` worktree.
No remote host was contacted. No `ssh` was run.
## 0. What the probes changed about the target
Four findings move the design. Read these before the cuts.
### 0.1 A player build already works **(probe)**
The target says Aetheria "has no player build pipeline"
(`docs/build-delivery-target.md:11`). Half true: there is no build *code* — zero
`BuildPipeline`/`BuildPlayer`/`BuildTarget` references in any tracked `.cs`, and
`Build/` holds only Eve asset bundles and logs, never a player. But the project
itself builds clean today with the stock command-line switch.
- Scripts compiled for StandaloneWindows64 with warnings only (`CS0067` on
`Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs:44-46`).
- ~9 minutes wall on Starfire with a warm `Library/`.
- Output 212 files, **352.4 MB** on disk; **189.8 MB** as a zip
(`Compress-Archive -CompressionLevel Optimal`, 35s).
- Scenes come from `ProjectSettings/EditorBuildSettings.asset:8-13`:
`Assets/Scenes/Main Menu.unity`, `Assets/Scenes/ARPG.unity`, both enabled.
- Scripting backend is Mono, not IL2CPP: `ProjectSettings/ProjectSettings.asset:660-661`
sets `scriptingBackend` for Android only, so Standalone takes the default.
- `Aetheria_BurstDebugInformation_DoNotShip/` is emitted beside the exe and must
not ship.
So Cut 1 is not "make a build possible". It is "make a build repeatable,
identified, and complete", which is a much smaller cut.
### 0.2 The catalog gap is silent, not loud **(probe)**
`ActionGameManager.cs:38-41` resolves `GameDataDirectory` as
`new DirectoryInfo(Application.dataPath).Parent.CreateSubdirectory("GameData")`,
and `:45` builds `CatalogPath` from it. In a player that is
`<build>\GameData\Aetheria.cc`. Nothing in the repo copies the catalog there.
The dangerous part is what happens next. `AetheriaStores.cs:13-15` promises the
open "Throws when a `[CultGlobal]` type routed to the catalog has no record", and
`:26-31` implements exactly that. But none of the seven catalog types at
`AetheriaStores.cs:9` is `[CultGlobal]`, so the guard selects nothing and the
open succeeds over a catalog file that does not exist.
Proven, not inferred: the built player launched, reached Main Menu, ran for 25s,
and wrote `<build>\GameData\player.cc` (1,964 bytes) — while `Aetheria.cc` was
absent the whole time. No exception, no log line about the catalog. The only
errors were `Stardust` compute-shader nulls, which are `-nographics` artefacts.
A player shipped without its catalog therefore does not refuse. It boots to a
menu and fails later, in the ARPG scene, with an empty world. That is worse than
a crash, and Cut 1 has to close it.
### 0.3 Idunn structurally cannot carry a file artifact
The target invariant reads "Idunn owns deployment to Yggdrasil"
(`docs/build-delivery-target.md:44`). Idunn cannot own this one.
- `Idunn/src/deployment.rs:33` — `TargetDeclaration.service` is a plain
`ServiceDeclaration`, not an `Option`. Every deployment unit is a service.
- `Idunn/src/deployment.rs:110-141` — a `ServiceDeclaration` requires an
`executable_artifact`, a transport, and a mandatory `health` declaration;
`:851` refuses a plan whose service executable is not a declared artifact.
- Admission does not complete until the workload publishes its own signed
presence and health (`Idunn/docs/deployment-authority.md:84-92`). A zip cannot
sign presence.
- The only workload drivers are `SystemdTransient` and `HostActuator`
(`Idunn/src/deployment.rs:442-450`) — both process supervisors. There is no
oneshot path.
- The only route drivers are `NginxStreamTcp`/`NginxStreamUdp`
(`Idunn/src/deployment.rs:698-701`), writing stream fragments to
`/etc/nginx/idunn-stream-routes/`. Idunn has no concept of an HTTP `root`,
`try_files`, or a web root anywhere in `src/`.
- Idunn has no push transport at all. It pulls: `GitSourceDriver`
(`Idunn/src/drivers.rs:682,714`) fetches the admitted ref on the host. Even the
remote Windows actuator fetches its own source
(`Idunn/docs/host-actuator.md:27-47`). Bytes never travel *from* Idunn.
`ArtifactOutput` (`Idunn/src/deployment.rs:91-101`) can carry an arbitrary blob
with an `expected_sha256`, and `required_adjacent_artifacts` (`:115`) ships
non-executable files — but only alongside a supervised executable. There is no
unit shaped like "a file at a URL".
Meanwhile Yggdrasil already serves static files, twice, through a different and
proven authority (see 0.4). The target invariant should be narrowed to "Idunn
owns *service* deployment to Yggdrasil"; static bytes already have an owner, and
it is not a second deployment authority invented for this migration. See **F1**.
### 0.4 The site is GitHub Pages; only the bytes can live on Yggdrasil
`gamecult.org` is GitHub Pages out of `GameCult/gamecult-site`
(`gamecult-ops/inventory.md:728-738`, `runbooks/gamecult-pages-cutover.md:1-19`),
built by the shared engine `GameCult/GameCult-Quartz` through a reusable workflow
(`gamecult-site/.github/workflows/deploy-quartz.yml`,
`GameCult-Quartz/.github/workflows/quartz-pages.yml:24-72`, ending in
`actions/deploy-pages@v4`). `aetheria.gamecult.org` is the same mechanism out of
`GameCult/AetheriaLore` (`AetheriaLore/site/quartz.config.ts:66`,
`gamecult-ops/inventory.md:801-806`).
A 190 MB zip cannot go in either repo: GitHub blocks files over 100 MB, and
Pages has its own soft limits. So the split is forced and is exactly what the
operator asked for — the *page* is on the site, the *bytes* are on Yggdrasil.
What Yggdrasil already has:
- nginx with real static vhosts: `gamecult-ops/nginx/erycina.org.conf:4`
(`root /srv/erycina-site/site/current`), `nginx/velvet.erycina.org.conf:4`.
- An immutable-release store pattern:
`scripts/deploy-erycina-site-yggdrasil.sh:33-50` unpacks into
`/srv/<site>/site/releases/<UTC-stamp>` and moves `current` atomically.
- A generic TLS publisher: `scripts/publish-static-site-tls-yggdrasil.sh:1-60`,
parameterised by `SITE_HOST`/`SITE_ROOT`, refusing to run unless DNS already
resolves to this host (`:40-56`). certbot/Let's Encrypt throughout.
- Transport: `scp ... ygg:` then `ssh ygg 'sudo -n bash /tmp/...'`
(`runbooks/erycina-org-site-deploy.md:78-81`).
One thing does **not** transfer. The site-release pattern rotates: a `current`
symlink plus `KEEP_RELEASES=5` pruning
(`scripts/deploy-erycina-site-yggdrasil.sh:50-56`). The target requires the
opposite — "A published version stays published"
(`docs/build-delivery-target.md:32`). The artifact store is append-only, with no
`current` symlink and no pruning. Cut 4 says so explicitly, because copying the
erycina script wholesale would silently delete published downloads.
## 1. Authority map (whole migration)
- **Owner of the release record:** a new CultCache store
`Releases/aetheria.releases.cc` in this repo, one `aetheria.release` document
per published build. The Aetheria repo owns the facts about Aetheria builds.
- **Inputs:** the built artifact's bytes (size, sha256), the commit it was built
from, the platform, the build host, the version, and a hand-authored changelog
entry.
- **Outputs:** the artifact zip; the release document; a derived feed and derived
changelog pages in the site repo.
- **Derived state, never authored:** the browser-facing JSON feed, the download
page, and every changelog page. All are projections of the release store,
regenerated wholesale. Editing them by hand is the failure the target names at
`docs/build-delivery-target.md:30-31`.
- **Forbidden writers:**
- The *game* must never read or write the release store. It is not added to
`AetheriaStores.cs:9-11`, and `ActionGameManager.cs:56` keeps opening exactly
three stores. A release record is publishing state, not game state.
- Idunn does not write the artifact, the feed, or the pages (0.3).
- The site repos never author release facts; their content files for downloads
and changelog are generated and overwritten.
- `Build/` stays gitignored (`.gitignore:8`) and never becomes an artifact
store.
- **Shared paths:** there is exactly one publish primitive. A manual publish, a
re-publish, and any future automated publish all go through the same
`release publish` verb; none of them writes the feed or the pages directly.
- **Deletion line:** the vacuous catalog guard at `AetheriaStores.cs:26-31` is
replaced before Cut 1 adds any build code, and
`docs/build-delivery-target.md:11-14` is corrected once Cut 1 lands.
**Atomicity, honestly.** The target asks that publishing "publishes the artifact,
the feed entry and the changelog entry together, or it publishes nothing"
(`:28-29`). Three destinations — a remote filesystem, a git-tracked store, and a
second git repo — admit no real two-phase commit. The invariant that actually
protects a visitor is narrower and is the one to enforce: **no feed entry exists
without verified bytes already fetchable at its URL.** Order is artifact → verify
by re-downloading and re-hashing → release document → projection → site push.
A failure before the release document leaves unreferenced bytes, which are inert
and which the next publish of the same version may overwrite only when the hash
matches. A failure after it leaves a pending page, which the next site build
fixes. Neither produces a download link that 404s.
## 2. Cuts
### Cut 1. A player build that is repeatable, identified, and carries its catalog
- **Repo/branch:** Aetheria, `codex/build-delivery` from `6dc18607`. Depends on
nothing.
- **First:** keep the probe numbers in 0.1 as the baseline — 212 files,
352.4 MB, 189.8 MB zipped, ~9 min warm. A first landed build that differs
wildly means the build options diverged from the stock switch.
- **Deletes first:**
- `Assets/Scripts/ServerShared/AetheriaStores.cs:13-15` (the comment promising
a guard that does not fire) and the guard body at `:26-31`, replaced rather
than supplemented.
- **Adds:**
- `Assets/Editor/AetheriaBuild.cs` — one static entry point for
`-executeMethod`, taking version and output root from the command line. It
goes under the existing `Assets/Editor/` (whose only current file is
`Epiphany/EpiphanyEditorBridge.cs`) and compiles into the existing
`Assembly-CSharp-Editor`; no new asmdef, no new assembly.
- A catalog staging step in that entry point.
- **Per-file changes** (against `6dc18607`):
- `Assets/Scripts/ServerShared/AetheriaStores.cs:16-32`: `Open` refuses a
`catalogPath` that does not exist, unless `catalogWritable` (the seed case
already exempted at `:24-25`). Keep the existing global check as a second,
narrower guard; it is not wrong, it is merely inert for today's type set, and
it will bite the moment a catalog type is marked `[CultGlobal]`. The new
check is the one that protects a shipped player.
- `Assets/Editor/AetheriaBuild.cs` (new): `BuildPlayerOptions` with
`scenes` read from `EditorBuildSettings.scenes` filtered on `enabled` — the
same two scenes at `ProjectSettings/EditorBuildSettings.asset:8-13`;
`target = BuildTarget.StandaloneWindows64`. Sets
`PlayerSettings.bundleVersion` from the passed version, superseding the
stale `0.1` at `ProjectSettings/ProjectSettings.asset:152`. After the build:
copy `GameData/Aetheria.cc`, `GameData/Narrative/**` and
`GameData/SoundbanksInfo.json` next to the exe; delete
`Aetheria_BurstDebugInformation_DoNotShip/`; fail the run on a non-empty
`BuildReport` error list.
- **Authority map:**
- Owner: `AetheriaBuild` owns what a player build contains.
- Inputs: `EditorBuildSettings.scenes`, `GameData/`, a version string, an
output root.
- Outputs: a complete player directory, and the build report.
- Derived: `PlayerSettings.bundleVersion` is now set by the build, not authored
in `ProjectSettings.asset`.
- Forbidden writers: nothing else sets `bundleVersion`; nothing else decides
the scene list.
- Deletion line: the inert-guard comment goes before the entry point is added.
- **Verification:**
- builds: the entry point produces a player; size within ~10% of 352.4 MB.
- negative: `git grep -n "Aetheria_BurstDebugInformation"` finds only the
deletion in `AetheriaBuild.cs`.
- test: a `Aetheria.Shared.Tests` case pins that `AetheriaStores.Open` throws
on a nonexistent read-only catalog path, and still opens a writable empty one
(the seed case at `AetheriaStores.cs:24-25`). This is the regression that 0.2
proves is live today.
- operator: launch the staged build by double-click, reach Main Menu, start a
game, confirm the world is populated. Then rename `GameData/` beside the exe
and relaunch: it must refuse loudly, not boot to a menu.
### Cut 2. The release record
- **Repo/branch:** Aetheria, same branch. Depends on Cut 1 for the fields it
records.
- **Adds:**
- An `aetheria.release` `[CultDocument]` type, declared in the tools assembly,
not in `ServerShared` — the game must not link it.
- `Releases/aetheria.releases.cc`, tracked in git. Small: a few KB per release.
- **Shape.** Model the fields on the one existing GameCult type that already
describes a versioned build artifact,
`CultLib/src/GameCult.Mesh/CultMeshCdn.cs:130-171`
(`gamecult.mesh.cdn_artifact_manifest`): `Version` (`:145`), `ContentHash`
sha256 (`:151`), `SizeBytes` (`:156`), `MimeType` (`:160`), `CreatedAtUtc`
(`:164`). Note `CultMeshCdnArtifactKinds.Build = "build"` at `CultMeshCdn.cs:34`
is documented as "Executable build or install/update payload" — this exact case.
Add what that type lacks and the target requires: `Commit`, `Platform`,
`BuildHost`, `Url`, and the changelog body. See **F7** on reuse vs a new type.
- **Authority map:**
- Owner: the release store is the authority on what exists
(`docs/build-delivery-target.md:30`).
- Inputs: Cut 3's publish verb only.
- Derived: everything a browser sees.
- Forbidden writers: the game (`AetheriaStores.cs:9-11` unchanged,
`ActionGameManager.cs:56` unchanged); the site repos; any hand edit.
- Deletion line: none — nothing exists to replace.
- **Verification:**
- negative: `git grep -n "aetheria.release"` must not match anything under
`Assets/Scripts/ServerShared/`. Checked against the `aetheria.*` schema names
already in that tree so it cannot collide.
- test: reopening the store yields the same documents; a published version is
refused on re-publish with different bytes.
### Cut 3. One publish primitive
- **Repo/branch:** Aetheria, same branch. Depends on Cuts 1 and 2.
- **Keeps and reuses:** `tools/AetherDb` — the existing headless .NET console over
Aetheria's CultCache stores, with store-path resolution already written at
`tools/AetherDb/AetherDb.cs:15-32`. A `release` verb group there adds **zero**
build targets and reuses the csproj, the `Aetheria.Shared` reference, and the
path walk. A second console would add a target for nothing. See **F5** on who
runs it.
- **Adds:** `release build`, `release publish`, `release project` verbs, and
`CHANGELOG.md` at the repo root in the Keep-a-Changelog shape CultLib just
adopted (`CultLib-semver/unity/org.gamecult.cultlib/CHANGELOG.md:1`).
- **Per-file changes:**
- `tools/AetherDb/Program.cs`: new verb dispatch. `release build` shells the
Unity entry point from Cut 1 and zips the result, excluding nothing further
(Cut 1 already removed the Burst directory). `release publish` computes
sha256 and size, reads the `CHANGELOG.md` section for the version, writes the
`aetheria.release` document, and emits the projection. `release project`
regenerates the projection alone from the store, so the pages can be rebuilt
without a build.
- Follow the deterministic-packaging precedent in
`CultLib-semver/scripts/build-unity-package.ps1:41-52`, which clears
intermediates and disables source-revision stamping so a rebuild is
byte-comparable. The target asks for reproducibility from a commit
(`docs/build-delivery-target.md:36-37`); say plainly in the doc what Unity
does *not* guarantee there rather than claim byte-identity that Cut 1 has not
demonstrated.
- **Authority map:**
- Owner: `release publish` is the only writer of the release store.
- Derived: the feed JSON and the markdown pages, regenerated wholesale, never
merged into.
- Shared paths: a first publish and a re-publish use the same verb.
- Forbidden writers: no script writes the feed or pages directly.
- **Verification:**
- test: `release project` run twice produces byte-identical output.
- negative: nothing outside `tools/AetherDb` opens `aetheria.releases.cc`.
- operator: none yet; Cut 5 carries the smoke.
### Cut 4. The artifact host on Yggdrasil
- **Repo/branch:** `gamecult-ops`, its own branch. Depends on Cut 3 producing a
zip. Independent of Cuts 1-3 landing.
- **Adds:**
- `nginx/<host>.conf` modelled on `nginx/erycina.org.conf`, keeping the ACME
location (`:11-14`) and the security headers (`:7-9`), but with
`root /srv/aetheria-builds`, `autoindex off`, and **no** `try_files`
fallback and **no** `error_page` rewrite — a missing version must 404.
- `scripts/deploy-aetheria-build-yggdrasil.sh`. Modelled on
`scripts/deploy-erycina-site-yggdrasil.sh` but with the rotation removed:
the release path is `/srv/aetheria-builds/<version>/`, it **refuses** to
write into a version directory that already exists, there is **no** `current`
symlink, and there is **no** pruning. Copying `:50-56` unchanged would delete
published downloads and break
`docs/build-delivery-target.md:32`.
- A runbook alongside `runbooks/erycina-org-site-deploy.md`.
- **Reuses unchanged:** `scripts/publish-static-site-tls-yggdrasil.sh` for the
vhost install and certbot run — it is already parameterised by
`SITE_HOST`/`SITE_ROOT` (`:1-60`) and already refuses to run before DNS
resolves to this host (`:40-56`).
- **Hazard to carry into the runbook:** five existing `*.gamecult.org` records in
`gamecult-ops/inventory.md` still say `A -> 94.130.223.131`
(`:746, :764, :771, :777, :785`) while Yggdrasil is at `159.195.114.249`
(`:363, :387`). The new record must use the current address, and the stale rows
are a follow-up outside this migration.
- **Authority map:**
- Owner: the release directory tree on Yggdrasil is append-only and root-owned.
- Inputs: a zip and its sha256, scp'd by the operator.
- Forbidden writers: nothing overwrites a published version directory; Idunn
does not touch this tree (0.3); no pruning job exists.
- Deletion line: the rotation and pruning logic is removed when the script is
adapted, not left in and disabled.
- **Verification:**
- operator: `curl -I` the artifact URL returns 200 with the right
`Content-Length`; TLS valid; the parent directory does not list;
a nonexistent version returns 404.
- negative: re-running the deploy script for an existing version exits nonzero
and changes nothing.
- budget: ~190 MB per release on a ~2 TB root
(`gamecult-ops/inventory.md:355-392`). Ten releases is ~1.9 GB. Retention
owner is the operator; append-only is the default and nothing prunes.
### Cut 5. The download page and the changelog pages
- **Repo/branch:** the chosen site repo (see **F2**), its own branch. Depends on
Cut 3's projection and Cut 4's URL.
- **Adds:** generated content files under the site's content dir — one downloads
page, one page per release — plus the feed JSON under `static/`.
- **Mechanism.** Do not write a Quartz emitter plugin. The established precedent
in these repos is pre-build generation into committed files:
`gamecult-site/scripts/generate-vn-repo-doc-tree.mjs` and
`generate-vn-eve-surface.mjs` write JSON into `GameCult/static/...`, invoked by
the local launcher (`scripts/quartz/quartz.ps1:38-52`) and **not** by CI, so the
outputs are committed artifacts. Cut 3's `release project` fills that same role.
The site's existing workflow then publishes with no change at all.
- **Brand.** Follow `gamecult-site/docs/brand-design-language.md` and the config
it describes, not invention: Montserrat 100-300 for titles, Ubuntu 300 body,
IBM Plex Mono uppercase and letter-spaced for the structural labels a download
page is mostly made of (version, date, platform, size, hash). Ground `#07111a`,
panels `#16212c`, body `#b7c7d9`, headings `#eef5ff`, accent `#ff8a2a`, links
`#59b7ff`. Reuse the existing component classes rather than adding a page-type
variant: `.gamecult-repo-facts` / `-fact-label` / `-fact-value`
(`gamecult-site/site/quartz/styles/custom.scss:953-1008`) is already a
label/value fact table, and `.gamecult-action`
(`custom.scss:2246-2360+`) is already the button. Note the brand defines no
success/warning/error colour (`brand-design-language.md:135-138`); a download
page needs none.
- **Authority map:**
- Owner: Cut 3's projector owns every byte of these files.
- Derived: all of them. A hand edit is overwritten on the next projection and
that is the intended behaviour.
- Forbidden writers: no human edits the generated pages; changelog prose is
authored in the Aetheria repo's `CHANGELOG.md` and flows through Cut 3.
- Shared paths: the site's existing Pages workflow is untouched.
- **Verification:**
- builds: the site's Quartz build succeeds with the generated pages present.
- negative: re-running `release project` leaves the working tree clean.
- **operator, the real check:** from a machine that has never held the repo —
open the download page on the public site, click the link, let it download,
verify the sha256 shown on the page against the file, unzip it, run
`Aetheria.exe`, start a game, confirm the world is populated. Then confirm
the changelog page for that version says what actually changed. This is the
only check that proves the whole path; every other verification above is a
component test.
## 3. Operator forks
**F1. Does Idunn carry the artifact?** *Most blocking — it decides Cut 4.*
- **A:** Extend Idunn with a static-artifact deployment unit (optional `service`,
a file workload driver, an HTTP root route driver).
- **B:** Use the existing `gamecult-ops` static release path — scp, a root-owned
append-only release script, an nginx vhost, certbot — the same authority shape
that already serves `erycina.org` and `velvet.erycina.org`.
- **Recommended: B.** A is a large change in Rust to the one daemon whose whole
design premise is that it depends on no managed target and supervises signed,
health-reporting processes; it would mean making `service` optional
(`Idunn/src/deployment.rs:33`) and inventing an HTTP-root route driver, to make
Idunn do the thing it is deliberately not shaped for. B is not a second
deployment authority — it is the *existing* file-serving authority on that
host. This does mean narrowing the target invariant at
`docs/build-delivery-target.md:44` to name service deployment; that edit
belongs to whoever lands Cut 4.
**F2. Which site owns the download page?**
- **A:** `gamecult.org` (`gamecult-site`) — what the target's invariant names
(`docs/build-delivery-target.md:46`).
- **B:** `aetheria.gamecult.org` (`AetheriaLore`) — the game's own live site,
same engine, same brand family.
- **Recommended: B, with A linking to it.** A download for Aetheria belongs on
Aetheria's site, and per-release changelog pages will accumulate; parking
dozens of them in the studio site's tree is noise there and natural there. The
honest counter: `AetheriaLore` is a *lore vault* (`Aetheria/Lore`, `Fiction`,
`Worldbuilding`) and release notes are not lore, so B needs a new top-level
section and a masthead entry
(`AetheriaLore/site/quartz/components/AetheriaMasthead.tsx:19-39` is a
hardcoded route list). This one is close; the target's wording points at A and
only the operator can say whether that wording was deliberate.
**F3. Versioning scheme for game builds.**
- **A:** semver, next is `0.2.0`, commit recorded as a separate field.
- **B:** CalVer, `2026.09.17`.
- **C:** commit-derived, `0.1.0+g6dc1860`.
- **Recommended: A.** It reuses the policy work already in flight:
`CultLib-semver/scripts/check-changelog-semver.mjs` demands exactly one
major/minor/patch step per release (`:39,:62`), refuses a version with no
changelog entry (`:79`), and treats `0.y.z` minor as the breaking lane
(`:98,:139`) — which is exactly right for a game whose saves will break.
Caveat the operator should know: that script is **untracked** on its branch,
and the `docs/semver-policy.md` it cites at `:4` does not exist in any CultLib
worktree. It is package-only and says nothing about builds, so adopting it here
is a decision, not an inheritance. B tells a player nothing about whether their
save survives. C puts a commit in the version string where the target already
wants the commit as its own field (`docs/build-delivery-target.md:26-27`).
**F4. Hand-written or commit-derived changelog?**
- **A:** Hand-written per release in `CHANGELOG.md`, ingested at publish.
- **B:** Derived from commit subjects between tags.
- **Recommended: A.** B produces a build log, not release notes — and GameCult
already has that experiment running by hand:
`gamecult-site/GameCult/Projects/VoidBot.md:53-57` and `Ghostlight.md:56-57`
are dated commit-subject lists, and they read like machine output. A also
matches what CultLib just adopted for its own packages. The cost is that
publishing blocks on someone writing prose, which is the correct thing for it
to block on.
**F5. Agent-run or operator-run publishing?**
- **A:** Fully agent-run, including the Yggdrasil push.
- **B:** Agent builds, hashes, writes the record and the projection, and opens
the PRs; the operator runs the two remote scripts.
- **Recommended: B.** `gamecult-ops` already draws this exact line, in a comment
worth quoting: "putting a name on the public internet is a separate decision
from having bytes on disk"
(`scripts/deploy-erycina-site-yggdrasil.sh:9-11`). A published version is
permanent by the target's own rule, so the irreversible step is the one a human
should take. Everything expensive and mechanical stays automated.
**F6. Zip or installer?**
- **A:** Zip. **B:** An installer (NSIS/Inno/MSI).
- **Recommended: A.** Measured at 189.8 MB **(probe)**. No installer toolchain
exists anywhere in GameCult, and B drags in code signing, an uninstaller, and a
new build dependency for no player benefit while launchers and auto-update are
already out of scope (`docs/build-delivery-target.md:55`). Revisit when there
is a reason, not before.
**F7. Reuse `gamecult.mesh.cdn_artifact_manifest` or declare `aetheria.release`?**
- **A:** Reuse the CultLib type (`CultLib/src/GameCult.Mesh/CultMeshCdn.cs:130`),
which already has version, sha256, size, mime, created-at, and a `Build` kind.
- **B:** A new `aetheria.release` type that borrows those field names.
- **Recommended: B, citing A.** The CultLib type is CDN-shaped: its `Chunks` list
(`:168`) is the point of it, and content-addressed chunking
(`CultMeshCdn.cs:96-120`) is machinery this migration does not use and should
not fake. A release also needs `Commit`, `BuildHost`, and changelog prose,
which do not belong bolted onto a CDN manifest consumed elsewhere. Borrow the
field shape, not the type. If a CultMesh CDN path ever carries these builds, A
becomes the transport and B stays the record.
## 4. Subtraction and build budget
This migration is net additive and cannot honestly be made otherwise: it buys a
capability that does not exist today — nobody outside Starfire can play the game.
What it must not do is buy it twice.
| Cut | Removes | Adds | Targets |
|---|---|---|---|
| 1 | inert guard + stale comment, `AetheriaStores.cs:13-15,26-31`; the shipped Burst debug dir | one Editor file under the existing `Assets/Editor/` | +0 assemblies (no asmdef; joins `Assembly-CSharp-Editor`) |
| 2 | — | one document type, one tracked `.cc` | +0 |
| 3 | — | verbs inside the existing `tools/AetherDb`; one root `CHANGELOG.md` | **+0 executable targets** |
| 4 | the rotation and pruning logic while adapting the erycina script | one nginx conf, one shell script, one runbook | +0 (no compilation in `gamecult-ops`) |
| 5 | — | generated markdown/JSON in a site repo | +0 (no workflow change) |
Explicit budget statements:
- **No new executable target anywhere.** Cut 3 reuses `tools/AetherDb` precisely
so the repo does not gain a second console. If that reuse is rejected, the
budget changes and the fork should be raised.
- **No new Unity assembly**, no new package dependency, no Addressables
dependency — `docs/addressables-cut.md` has not landed and this migration does
not wait on it. The build probed in 0.1 is the pre-Addressables `Resources`
path, and it works.
- **Build host is Starfire, Windows, Unity 6000.3.24f1**
(`ProjectSettings/ProjectVersion.txt:1`). The serving host is Debian 13. The
artifact is Windows-only and produced only on Windows; nothing about Cut 4
proves anything about the player, and nothing about Cut 1 proves anything about
the server.
- **Disk.** ~190 MB per release on Yggdrasil, append-only, no pruning. The
operator owns retention.
- **A fresh clone is not required per release.** The checkout is 1.78 GiB of
`.git` plus a 2.6 GB `Library/`; the ~9 min build in 0.1 was warm, and a cold
`Library/` import is far worse. Publishing builds from the working Starfire
checkout is the assumption; if that ever moves to a clean builder, the import
cost has to be budgeted then, not assumed away now.
## 5. Open
- Every fork in section 3 is unruled.
- `docs/build-delivery-target.md:11-14` and `:44` both need correcting once F1
and Cut 1 are ruled; the target currently says builds happen by hand into
`Build/` (they do not — nothing produces a player) and that Idunn owns this
deployment (it structurally cannot).
- Follow-up outside this migration: the five stale `A -> 94.130.223.131` DNS rows
in `gamecult-ops/inventory.md:746,764,771,777,785`.
- Follow-up outside this migration: `CultLib-semver/scripts/check-changelog-semver.mjs`
is untracked and cites a `docs/semver-policy.md` that does not exist. F3 leans
on that work; someone should land it there.

View file

@ -41,8 +41,11 @@ artifact: Idunn deploys services to Yggdrasil, not files.
- **State is CultCache, not JSON** (`F:\Projects\CLAUDE.md`). The release
record and the changelog are typed documents. Anything a browser eats is a
projection at the xenos boundary, and is derived, never authored.
- **Idunn owns deployment to Yggdrasil.** Publishing does not invent a second
deployment authority, and does not require Idunn to be up in order to build.
- **Idunn owns *service* deployment to Yggdrasil**, and only that. Probed
2026-09-17 (`docs/build-delivery-cut.md`): every Idunn unit is a supervised
process that must publish signed health, and Idunn pulls rather than pushes, so
it cannot carry a file artifact. Publishing uses the host's existing static-file
path and does not invent a second *service* deployment authority.
- **The site's brand is the site's** (`F:\Projects\gamecult-site`), and the
download and changelog pages follow it.
- **The build host is Windows.** Unity 6000.3.24f1 builds on Starfire; the