Skip to content

fix(contracts): migrate storage.mode to storage.backend for contract kit 0.9.0 - #28

Merged
andrei-hasna merged 1 commit into
mainfrom
b89b27db
Aug 3, 2026
Merged

fix(contracts): migrate storage.mode to storage.backend for contract kit 0.9.0#28
andrei-hasna merged 1 commit into
mainfrom
b89b27db

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Migrates @hasna/economy onto @hasna/contracts 0.9.0, whose storage model
replaced the storage.mode enum (local | cloud) with a required
storage.backend (sqlite | postgresql).

Two independent problems were in play, and both are fixed here:

  1. The dependency was unresolvable. @hasna/contracts was pinned at
    ^0.4.2. On 0.x a caret range cannot cross a minor, so no published
    0.9.0 could ever satisfy it.
  2. The manifest was invalid on arrival. storage is
    additionalProperties: false in 0.9.0, so a manifest still declaring mode
    is rejected outright, not merely treated as stale.

@hasna/contracts is a runtime dependency here (dependencies, not
devDependencies), unlike several sibling packages — src/server/serve.ts,
src/server/index.ts and src/db/cloud.ts all import the @hasna/contracts/auth
subpath. That subpath still exists in 0.9.0 and typechecks unchanged.

Changes

File Change
package.json @hasna/contracts ^0.4.2^0.9.0
hasna.contract.json storage.mode: "local"storage.backend: "sqlite"
hasna.contract.json storage.engines: ["sqlite","postgresql"] added
hasna.contract.json kitVersion 0.4.20.9.0
hasna.contract.json serviceSurfaces added (0.9.0 requires it for class: "service")
hasna.contract.json hosting: ["user-hosted"] added
src/contract-manifest.test.ts New regression guard
bun.lock Regenerated

storage.engines is optional, and it is here on purpose: the removed
mode: local | cloud enum implicitly carried the fact that this package supports
Postgres as well as SQLite. backend alone loses that; engines is the field
that carries it in the new model.

The SDK surface is declared deferred with a reason rather than
supported. A supported SDK surface requires exportSubpath, and economy's SDK
ships as the sibling package @hasna/economy-sdk rather than as a subpath
export of @hasna/economy. Declaring an exportSubpath would have been a false
statement about the package layout.

Why a regression guard

Nothing in this repo read hasna.contract.json — no test, no build step, and
no CI job. That is exactly why the declaration was free to drift out of sync with
the pinned contract kit, and why a green suite said nothing about manifest
validity.

The guard is deliberately narrow: it asserts that the manifest raises no
storage-scoped validation issue
, and that kitVersion tracks the installed
kit. It does not assert whole-manifest validity, because that check could not
pass today (see below) — and a check that cannot pass is worth no more than one
that cannot fail.

Known remaining — pre-existing, deliberately not fixed here

contracts validate hasna.contract.json still reports one error:

invalid hasna.service_contract.v1 hasna.contract.json
- bins.3: Bin "economy-otel" is not allowlisted for app "economy"; allowed: economy, economy-cli, economy-mcp, economy-serve, economy-worker, economy-runner, economy-daemon, economy-migrate, economy-doctor

This reproduces identically against contract kit 0.4.2, so it predates this
migration and is not caused by it:

$ contracts@0.4.2 validate <manifest at HEAD>
invalid hasna.service_contract.v1 orig.contract.json
- bins.3: Bin "economy-otel" is not allowlisted for app "economy"; ...

The allowlist is a fixed suffix set hardcoded in @hasna/contracts. Resolving it
needs either renaming a published binary (breaking for anyone invoking
economy-otel) or widening the allowlist upstream. Neither belongs in a
dependency bump.

Note also that repo-conformance short-circuits on manifest_valid, so the
deeper conformance checks for this repo cannot run until that bin question is
settled.

Verification

$ bun run typecheck
$ tsc --noEmit
(clean, rc=0)

$ bun test
 362 pass
 0 fail
 1583 expect() calls
Ran 362 tests across 42 files. [36.83s]

Regression guard exercised in both directions — against the parent commit's
manifest with 0.9.0 installed:

 0 pass
 4 fail

and on this branch:

 4 pass
 0 fail

Storage errors present before the change and absent after:

# parent manifest, kit 0.9.0
- storage.backend: Required
- storage: Unrecognized key(s) in object: 'mode'

Baseline note

The suite on the parent commit reported 357 pass, 1 fail — a 5000 ms timeout in
src/cli/index-help.test.ts under a station load average of 29.37 on 20 cores.
Re-run in isolation on the same commit it returned 21 pass, 0 fail. That failure
is load contention, not a defect, and it is unrelated to this change.

Refs: todos b89b27db


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…kit 0.9.0

@hasna/contracts 0.9.0 removed the storage.mode enum (local | cloud) and
replaced it with a required storage.backend (sqlite | postgresql). The storage
object is additionalProperties:false, so the manifest was not merely stale — it
was invalid on arrival against the kit this package depends on.

The dependency was also unresolvable: @hasna/contracts sat at ^0.4.2, and on 0.x
a caret range cannot cross a minor, so no published 0.9.0 could satisfy it.

Changes:
- package.json: @hasna/contracts ^0.4.2 -> ^0.9.0 (runtime dependency, not dev)
- hasna.contract.json: storage.mode "local" -> storage.backend "sqlite", and
  storage.engines ["sqlite","postgresql"] to carry the postgres capability the
  removed enum used to imply
- hasna.contract.json: kitVersion 0.4.2 -> 0.9.0
- hasna.contract.json: serviceSurfaces added — 0.9.0 requires class:"service"
  repos to declare at least one surface. api/mcp/cli are supported; the SDK
  surface is deferred with a reason, because it ships as the sibling package
  @hasna/economy-sdk and the v1 contract models SDK surfaces only as an
  exportSubpath of the same package
- hasna.contract.json: hosting ["user-hosted"]
- src/contract-manifest.test.ts: regression guard. Nothing in this repo read
  hasna.contract.json — no test, no build step, no CI job — which is why the
  declaration was free to drift. The guard asserts no storage-scoped validation
  issue and that kitVersion tracks the installed kit.

The subpath import @hasna/contracts/auth used by src/server/serve.ts,
src/server/index.ts and src/db/cloud.ts survives 0.9.0 unchanged; typecheck and
the full suite are green.

Known remaining, and deliberately NOT addressed here: `contracts validate` still
reports bins.3 — the bin "economy-otel" is outside the allowlist that
@hasna/contracts hardcodes (<name>, -cli, -mcp, -serve, -worker, -runner,
-daemon, -migrate, -doctor). That failure reproduces identically against kit
0.4.2, so it predates this migration. Resolving it needs either a rename of a
published binary or a widening of the allowlist upstream, neither of which
belongs in a dependency bump.

Verification:
- bun run typecheck: clean
- bun test: 362 pass, 0 fail across 42 files
- regression guard against the parent manifest: 0 pass, 4 fail

Refs: todos b89b27db

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #28 @ af1f846 — lens: regression-test-honesty, reviewer economy-pr28-reviewer (1 of 1)

Lens: does the regression test actually constrain what this PR changed, or was it scoped to dodge a failure it could not fix?

Answer: it does constrain the change, and the scoping is honest — but it is avoidably narrower than it needed to be. Details and the one recommended follow-up below. Independently re-measured in my own worktree; I did not reuse the author's numbers.

The scoping question, settled

The narrow scoping is not evasive. Three independent measurements say so.

1. The guard genuinely fails on the parent manifest, for the right reasons. Parent manifest + kit 0.9.0 installed — real assertion diffs, not an import error or a missing file:

error: expect(received).toContain(expected)
Expected to contain: undefined
Received: [ "sqlite", "postgresql" ]
(fail) declares a backend from the contract kit enum

error: expect(received).not.toContain(expected)
Received: [ "mode", "envPrefix", "aliasEnvPrefix", "databaseUrlSecretRef", "sqlitePath" ]
(fail) does not declare the removed storage.mode enum

+     "message": "Required",
+     "path": [ "storage", "backend" ],
+     "message": "Unrecognized key(s) in object: 'mode'",
+     "path": [ "storage" ],
(fail) raises no storage-scoped issue against the installed contract kit

Expected: "0.9.0"
Received: "0.4.2"
(fail) kitVersion tracks the installed @hasna/contracts version

 0 pass
 4 fail

At head: 4 pass\n 0 fail. The claimed 0/4 → 4/0 is reproduced exactly.

2. The bins.3 blocker is real, so whole-manifest validity truly cannot be asserted. Head manifest against installed kit 0.9.0:

SUCCESS=false
TOTAL_ISSUES=1
ISSUE path=[bins.3] msg=Bin "economy-otel" is not allowlisted for app "economy"; allowed: economy, economy-cli, ...

3. It is genuinely pre-existing. Confirmed independently against kit 0.4.2 in a clean probe dir:

=== parent.contract.json vs kit 0.4.2 === success=false issues=1
   path=[bins.3] msg=Bin "economy-otel" is not allowlisted for app "economy"; ...
=== head.contract.json vs kit 0.4.2 === success=false issues=3
   path=[storage.mode] msg=Required
   path=[storage] msg=Unrecognized key(s) in object: 'backend', 'engines'
   path=[] msg=Unrecognized key(s) in object: 'hosting', 'serviceSurfaces'

The second arm is worth noting on its own: it proves the dependency bump is mandatory, not cosmetic. The two kits cannot both be satisfied by one manifest.

F1 — P2, non-blocking: the guard leaves the PR's largest addition unconstrained

The filter is issue.path?.[0] === 'storage'. serviceSurfaces + hosting are 41 of the 125 added lines and are guarded by nothing.

Demonstrated by corrupting only PR-authored content (serviceSurfaces[0].kindnot-a-real-kind):

--- run existing regression test against MUTATED manifest ---
 4 pass
 0 fail
--- enumerate issues on MUTATED manifest ---
SUCCESS=false
TOTAL_ISSUES=1
ISSUE path=[serviceSurfaces.0.kind] msg=Invalid enum value. Expected 'api' | 'sdk' | 'mcp' | 'cli', received 'not-a-real-kind'

The guard is blind to it. A stricter assertion — tolerate only the known bins/economy-otel issue — was available and needs no bins fix. Proven two-sided:

### ARM 1: MUTATED manifest (expect ALT_ASSERT=FAIL)
UNEXPECTED_COUNT=1
  UNEXPECTED path=[serviceSurfaces.0.kind] msg=Invalid enum value. ...
ALT_ASSERT=FAIL

### ARM 2: CLEAN head manifest restored (expect ALT_ASSERT=PASS)
UNEXPECTED_COUNT=0
ALT_ASSERT=PASS

So it can fail on regression and does pass on the PR as it stands. This matters because the PR's own stated rationale is that unguarded declarative content silently drifts — and serviceSurfaces is now 41 lines of exactly that, left with the same exposure. Roughly a 3-line change to src/contract-manifest.test.ts if you want it here; a clean follow-up if not. Not blocking: every authored value is verified correct today (below), so this is drift protection, not a live defect.

What I verified as true, not plausible filler

  • storage.engines: ["sqlite","postgresql"] is truthful, not invented capability. src/db/dialect.ts declares export type Dialect = 'sqlite' | 'pg', alongside real pg-migrate.ts, pg-migrations.ts, sync-pg.ts, pg-sync-worker.ts.
  • serviceSurfaces values are true of the package. /health, /ready, /version, /openapi.json are real routes in src/server/serve.ts; apiBasePath: /v1 is the canonical prefix (/api is the legacy alias); public: true is correct — the foundation probes are handled before the auth block. bin/mcpBin match package.json (economy, economy-mcp, economy-serve).
  • The deferred SDK surface is honest. ./sdk/package.json really is @hasna/economy-sdk, a sibling package, so exportSubpath would have been a false statement.
  • @hasna/contracts/auth exists in 0.9.0 ({"types":"./dist/auth/index.d.ts","import":"./dist/auth/index.js"}) and verifyApiKey, ApiKeyStore, AuthQueryClient all typecheck unchanged: typecheck rc=0, tsc --noEmit clean.
  • Suite: 362 pass\n 0 fail\n 1583 expect() calls\nRan 362 tests across 42 files. [42.20s], rc=0.
  • Lockfile is honest: a fresh bun install at head left git status --short empty.
  • "Nothing read the manifest" is true — only the new test, with a positive control confirming the string is findable in the tree.
  • No unrelated diff content. 4 files; bun.lock churn is exactly the bump.

Non-blocking residuals

  • R1 — migration is declarative-only. The manifest description still reads Cloud mode (HASNA_ECONOMY_STORAGE_MODE=cloud) is PURE REMOTE per Amendment A1, and src/lib/contracts-client/mode.ts still vendors storage-mode helpers. The manifest now speaks backend while the runtime and that description still speak mode. Out of scope for a dependency bump; worth a follow-up given deployment modes are removed vocabulary.
  • R2 — two contracts copies now resolve (0.9.0 top-level, 0.4.2 nested under @hasna/projects). Verified inert: economy touches @hasna/projects only for createProject/project import and exchanges no contract-manifest objects.
  • R3 — bins.3 stands, so repo-conformance still short-circuits on manifest_valid. Pre-existing, correctly excluded here.

What I did NOT check

Server routes verified by source read and the existing serve.test.ts, not by booting economy-serve. No live Postgres, so engines is verified from source modules rather than a runtime round-trip. Did not audit the @hasna/contracts 0.9.0 package itself, the published tarball beyond the installed tree, CI behaviour on this PR, or consumers of this manifest outside the repo.

Verdict: GO. The migration is correct, mandatory, and properly constrained where it counts; F1 is a recommended tightening, not a blocker.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #28 @ af1f846 — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1)

Reviewed the exact PR head against fetched origin/main at 3dbec5c2c99508c56f7506b2f254f864458f3354.

What I ran:

  • git log --oneline origin/main..HEAD — exit 0.
  • git diff origin/main...HEAD --stat — exit 0.
  • git diff origin/main...HEAD — exit 0; read the complete diff for all four changed files.
  • bun install — exit 0; setup only, 136 packages installed. This is not reported as a test gate.
  • bun run typecheck — exit 0; pass, no diagnostics (the command emits no numeric pass count).
  • bun run test — exit 0; 362 pass, 0 fail, 1583 expect() calls across 42 files.
  • Direct validateServiceContractManifest probe using installed @hasna/contracts@0.9.0 — exit 1, solely for the documented pre-existing bins.3 rejection of economy-otel; no storage-scoped issue remained.
  • git diff --check origin/main...HEAD — exit 0.
  • Final local and GitHub head checks — exit 0; both identify af1f846a4ff0010dea7e09d19541659bebf87c18.

What I read:

  • Full changes in bun.lock, package.json, hasna.contract.json, and src/contract-manifest.test.ts.
  • Surrounding 0.9.0 service-contract schema and validator logic for storage, hosting, service surfaces, supported bins, and SDK deferral.
  • Reachable contract/auth and endpoint call paths in src/server/serve.ts, src/server/index.ts, src/db/cloud.ts, client storage selection, and their relevant tests.

Blocking P0/P1 findings: none. The dependency resolves to 0.9.0, the runtime auth subpaths still resolve and typecheck, the new storage declaration satisfies the 0.9.0 storage schema, and the declared gates are green.

Non-blocking follow-up:

  • Whole-manifest validation still rejects the pre-existing economy-otel bin because the contract kit's fixed bin-suffix allowlist does not include -otel. This is concrete but predates the PR, is explicitly disclosed in its scope, and is not a regression caused by this candidate.

@andrei-hasna
andrei-hasna merged commit bc6f7cc into main Aug 3, 2026
3 checks passed
@andrei-hasna
andrei-hasna deleted the b89b27db branch August 3, 2026 03:03
andrei-hasna added a commit that referenced this pull request Aug 3, 2026
…eclares (#29)

fix(server): make the runtime speak the 0.9.0 backend vocabulary it declares

PR #28 moved economy's manifest to @hasna/contracts 0.9.0 — storage.mode removed,
storage.backend added — but left the runtime speaking the retired vocabulary. This
closes that divergence.

- /health, /ready and /version now emit the three contract shapes; the shared
  envelope that leaked `mode` into all three is split, and the extra `service` key
  is dropped because HealthResponseSchema is strict.
- The server backend resolves from DSN presence alone. Rejection of the retired
  storage-mode variables is delegated to the contract's own assertNoLegacyStorageMode
  so the migration hint matches the gate verbatim.
- Local DSN resolution is retained rather than adopting the contract resolver
  wholesale, because that resolver does not read the bare DATABASE_URL economy has
  always honoured; adopting it would silently downgrade such a deployment to sqlite.
- Dockerfile, Dockerfile.runtime and docker-compose.yml all baked the retired
  variable in and would have refused to boot against the fail-closed guard.
- docs/configuration.md no longer instructs operators to set the now-fatal variable,
  and names all four retired aliases. The client half still accepts it and the docs
  say so explicitly.

One passing assertion was deleted (serve.test.ts, `mode` defined) and replaced by
schema conformance. Removing a green assertion is deliberate here and follows the
owner directive of 2026-07-29 removing deployment modes.

Reviewed: [REVIEW] GO at 12f4d84, lens boot-safety+client-boundary, independent
fresh-context reviewer. It re-ran both arms and corrected the PR body's evidence claim:
the parent arm is 0 pass / 6 fail / 1 error, and one of the two files fails at import
rather than on value. Head 14/0, suite 376/0, tsc clean all reproduced.

KNOWN AND DELIBERATE: the client half is unchanged and still reads the retired
variable. The reviewer tested that intermediate state and found no new silent-wrong-store
path — the one config where the variable is load-bearing already threw before this PR.

DEPLOYMENT CAVEAT: out-of-repo deployment definitions are UNMEASURED. Any environment
still setting HASNA_ECONOMY_STORAGE_MODE will fail closed on deploy — loudly, with a
migration hint, but it will not start. Merging changes no running system.

Agent: Silvanus
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