fix(server): make the runtime speak the 0.9.0 backend vocabulary it declares - #29
Conversation
…eclares PR #28 migrated the contract MANIFEST to @hasna/contracts 0.9.0 (storage.mode -> storage.backend) but left the runtime untouched, so economy DECLARED the new vocabulary and DID the old one. Measured on main @ bc6f7cc with the contract's own HealthResponseSchema: backend: Required <root>: Unrecognized key(s) in object: 'mode', 'service' GET /health keys: [ "status", "version", "mode", "service" ] Both 0.9.0 conformance gates were failing: health_shape -- /health must match { status, version, backend } server_backend_configuration -- DATABASE_URL selects postgresql; retired mode variables fail closed Changes, per site: - src/db/cloud.ts: isCloudMode() branched on HASNA_ECONOMY_STORAGE_MODE, which is exactly the "normalized or silently mapped" behaviour CONTRACT.md section 2 bans. Replaced with resolveEconomyServerBackend(), which delegates rejection to the contract's assertNoLegacyStorageMode (so the migration hint matches the gate verbatim) and selects the backend from DSN presence alone. DSN resolution stays local because economy also honours the bare DATABASE_URL alias, which the contract's own resolver does not read -- deferring wholesale would silently downgrade such a deployment to sqlite. - src/server/serve.ts: the three foundation probes shared one envelope, which is what leaked `mode` into all of them. Split into the CONTRACT.md section 4 shapes: /health { status, version, backend }, /version { version }, /ready { ready, reason? }. HealthResponseSchema is strict, so the `service` key had to go too. - src/server/index.ts: `version` subcommand reported the retired mode; now reports backend, and fails closed with the migration hint rather than a stack trace. Help text updated. - openapi/economy.json (+ regenerated src/openapi.ts): the shared Foundation schema became strict Health / Ready / Version schemas. Edited at the JSON source and regenerated via scripts/sync-openapi.ts, not hand-edited. - Dockerfile, Dockerfile.runtime, docker-compose.yml: removed the baked-in HASNA_ECONOMY_STORAGE_MODE=cloud. This is REQUIRED, not cosmetic -- with the guard now failing closed, a surviving variable would refuse to boot the container. The backend is selected by HASNA_ECONOMY_DATABASE_URL, which every affected service already sets. Deleted a PASSING assertion, deliberately: src/server/serve.test.ts asserted `health.mode` was defined. Owner directive 2026-07-29 removed the deployment-mode axis (no mode enums, no mode branching, no mode vocabulary), and @hasna/contracts 0.9.0 retired the variables outright, so a test demanding a `mode` key pins the defect rather than the contract. It is replaced by schema conformance against the contract's own schemas, not merely dropped. Regression tests first, both failing at the parent commit: - src/server/foundation-probe.contract.test.ts asserts the probe payloads against HealthResponseSchema / ReadyResponseSchema / VersionResponseSchema -- the exact instrument the health_shape gate runs. It imports nothing this fix introduces, so it RUNS and fails on the payload value at bc6f7cc rather than dying at import. - src/db/server-backend.contract.test.ts pins backend selection, the bare DATABASE_URL alias, and fail-closed rejection of all four retired variables. The rejection assertions match /was removed/ rather than bare .toThrow(), which a missing export would also satisfy. Verified: 5/5 fail at parent on value, 14/14 pass at head, full suite 376 pass / 0 fail, tsc --noEmit clean. Live server confirms {"status":"ok","version":"0.3.8", "backend":"sqlite"}, bare DATABASE_URL yields "backend":"postgresql", and a retired variable exits 1 with the migration hint. Scope: this closes the SERVER half. The CLIENT transport half (src/lib/ cloud-storage.ts, src/lib/contracts-client/, src/mcp/server.ts) still reads the retired variables and is deliberately left to a separate PR -- it changes CLI behaviour for every operator whose environment sets HASNA_ECONOMY_STORAGE_MODE, which is a different blast radius that deserves its own review. Refs: todos b89b27db Agent: Silvanus
|
[REVIEW] GO — #29 @ 12f4d84 — lens: boot-safety+client-boundary, reviewer economy-pr29-reviewer (1 of 1) Reviewed in two fresh worktrees pinned to VerdictGO. The change does exactly what it says, the fail-closed path is loud and self-serviceable, and the excluded client half is a defensible boundary — I tried to break it and could not. Four residuals, none blocking; two are one-line doc edits I recommend landing here before un-drafting. 1. Both test arms, re-run independentlyHead arm — reproduces the claim exactly: Parent arm — does NOT match the claim. The two new files were copied into the parent tree and run there:
That is unavoidable for a test pinning a new export, and the author mitigated it correctly ( Full suite and typecheck reproduce:
2. Boot-breakage enumeration — in-repo surface is complete and cleanEvery in-repo site that could set a retired variable, all four names (
The migration hint is good enough to self-serve. Measured, not reasoned — rc=1, one line, no stack trace, names the variable, the removal, and both replacement paths. Cleared, so nobody re-checks them: no container 3. The client boundary — defensible, and I tested the specific way it could have been unsafeThe half-state is real and I reproduced it in one shell, same binary, same variable: So one binary answers two contracts for one variable name. The question is whether that is unsafe, and the concrete hazard I went after is: an operator follows the hint, deletes the variable, and their CLIENT silently downgrades from cloud to local — a silent wrong store, which is precisely what CONTRACT.md §2 exists to prevent. It does not happen. Probing B is the finding: deleting the variable per the hint leaves the client on cloud, because URL+key is independently sufficient. C is the only configuration where the variable is load-bearing, and it already throws loudly and did so before this PR. That, plus the fact that the client half is byte-unchanged from Judgement: server-fixed / client-divergent is a coherent intermediate state, not an unsafe half-state. Its cost is coherence and documentation, not safety — and the documentation half is F1 below. 4. The deleted assertion — coverage is stronger, not thinner
5. The generated file matches its generator — verified, with a controlRan That check is only meaningful if the generator actually writes, so I proved it can fail: The generator rewrote my perturbation away. The empty result is a real match, not a no-op. 6. The resolver divergence is sound
FindingsF1 · P1 ·
After this PR that is not merely stale, it instructs the operator to perform the action that now refuses to boot the server. The exclusion boundary was drawn at the file, but this line belongs to the half you changed. Not a blocker only because the resulting failure is loud and the hint is self-serviceable (measured above). Line 44 in the same file is client-scoped and correctly stays for task F2 · P2 · F3 · P2 · It goes straight to connecting; no guard, no hint. Pre-existing, therefore non-blocking — F4 · P2 · PR body evidence. Parent arm is F5 · P3 · observation, pre-existing. What I did NOT check
|
… mode var The self-hosted server section still said the server "switches to direct Postgres mode when HASNA_ECONOMY_STORAGE_MODE=cloud". After this branch's runtime change that is inverted: resolveEconomyServerBackend selects the backend from the DSN alone and calls assertNoLegacyStorageMode first, so the variable now stops the server at startup instead of configuring it. Say what the runtime does -- backend follows the database URL, retired mode variables fail closed with a migration hint -- and name the three sibling aliases that fail the same way. The CLI/MCP client section is deliberately untouched: resolveEconomyCloudStorage is economy's own resolver and still honours the mode variable, so the client documentation at line 44 remains accurate. The new paragraph says the fail-closed behaviour is server-only so the two sections cannot be read as contradicting. Also drops "cloud" from "a non-loopback cloud server", which was a dangling reference to the mode the preceding line no longer describes. Agent: Silvanus
The defect
PR #28 migrated the contract manifest to
@hasna/contracts0.9.0 (storage.mode→storage.backend,kitVersion 0.9.0). That half is correct and mandatory. The runtime was not touched, so economy declared the new vocabulary and did the old one.Measured on
main@bc6f7ccwith the contract's ownHealthResponseSchema— the exact instrument thehealth_shapegate runs:Two 0.9.0 conformance gates were failing:
health_shape/healthmatches{ status, version, backend }server_backend_configurationpostgresql; retired mode variables fail closedWhat changed, per site
src/db/cloud.ts—isCloudMode()branched onHASNA_ECONOMY_STORAGE_MODE, which is precisely the "normalized or silently mapped" behaviour CONTRACT.md §2 bans. Replaced withresolveEconomyServerBackend(): rejection delegated to the contract'sassertNoLegacyStorageMode(so the migration hint matches the gate verbatim), backend selected from DSN presence alone.src/server/serve.ts— the three probes shared one envelope, which is what leakedmodeinto all of them. Split into the §4 shapes:/health {status,version,backend},/version {version},/ready {ready,reason?}.HealthResponseSchemais strict, so theservicekey had to go too.src/server/index.ts—versionsubcommand reportsbackend; fails closed with the hint instead of a stack trace.openapi/economy.json(+ regeneratedsrc/openapi.ts) — sharedFoundationschema split into strictHealth/Ready/Version. Edited at the JSON source and regenerated viascripts/sync-openapi.ts; the.tsis a generated mirror and was not hand-edited.Dockerfile,Dockerfile.runtime,docker-compose.yml— removed the baked-inHASNA_ECONOMY_STORAGE_MODE=cloud. Required, not cosmetic: with the guard failing closed, a surviving variable refuses to boot the container. Every affected service already setsHASNA_ECONOMY_DATABASE_URL.One passing assertion was deleted, deliberately
src/server/serve.test.tsassertedhealth.modewas defined, and it passed. Owner directive 2026-07-29 removed the deployment-mode axis (no mode enums, no mode branching, no mode vocabulary) and 0.9.0 retired the variables outright, so a test demanding amodekey pins the defect rather than the contract. It is replaced by schema conformance against the contract's own schemas, not merely dropped.A design decision worth reviewing
The contract's own
resolveServerDataBackendreadsHASNA_ECONOMY_DATABASE_URLandECONOMY_DATABASE_URL— not the bareDATABASE_URL, which economy has always honoured and which themigratecommand documents. Deferring to it wholesale would silently downgrade such a deployment tosqlite: a wrong backend reported as healthy. So rejection is delegated to the package while DSN resolution stays local, and there is a test pinning the alias.Evidence
Regression tests written first, both failing at the parent:
src/server/foundation-probe.contract.test.ts— asserts payloads againstHealthResponseSchema/ReadyResponseSchema/VersionResponseSchema. It imports nothing this fix introduces, so it runs and fails on payload value atbc6f7ccrather than dying at import. (First draft did die at import; that is a weaker signal and was fixed.)src/db/server-backend.contract.test.ts— pins backend selection, the bareDATABASE_URLalias, and fail-closed rejection of all four retired variables. Rejection assertions match/was removed/rather than bare.toThrow(), which a missing export would also satisfy.Live server, not just unit tests:
Scope — the client half is NOT in this PR
The client transport (
src/lib/cloud-storage.ts,src/lib/contracts-client/,src/mcp/server.ts,docs/configuration.md) still reads the retired variables. 0.9.0 retires them on that axis too — itsassertNoLegacyClientModerejects identically — so this work is real and remains open under taskb89b27db.It is deliberately excluded because it changes CLI behaviour for every operator whose environment sets
HASNA_ECONOMY_STORAGE_MODE, and would fail closed on their machines.src/lib/cloud-storage.test.tscarries 8 currently-passing assertions that a migration must delete. That is a different blast radius and deserves its own review rather than riding along here.What was NOT checked
backend: "postgresql"was verified from DSN resolution only, not by connecting.contracts conformanceCLI was not run end to end; gate satisfaction is demonstrated against the same schemas and resolver the gate imports.Refs: todos
b89b27dbNeed help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.