Skip to content

fix(contracts): migrate the service manifest off the removed deployment-modes axis - #200

Draft
andrei-hasna wants to merge 1 commit into
mainfrom
09f84e62
Draft

fix(contracts): migrate the service manifest off the removed deployment-modes axis#200
andrei-hasna wants to merge 1 commit into
mainfrom
09f84e62

Conversation

@andrei-hasna

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

Copy link
Copy Markdown
Contributor

Migrates the published service contract manifest off the removed
local|self_hosted|cloud placement axis, and moves the @hasna/contracts pin to
0.10.0 because the two are coupled.

Opened as a draft. check:contracts is still rc=1 and one test fails — deliberately.
See "This is not green, and why" below. Do not merge on the strength of the manifest
edits alone.

Todos row: 09f84e62-0fd5-420f-92ca-32fa6839cb79


The one decision that was not mechanical

storage.mode: "local" -> storage.backend: "postgresql". Not sqlite.

mode: "local" is not evidence of sqlite, and could not have been: the 0.5.2 enum was
["local","cloud"] — a placement axis with no way to express a storage backend at all.

What loops-serve — the binary named in serviceSurfaces[0].bin — actually opens:

evidence source
"The service reads and writes self-hosted RDS/Postgres directly. There is no local SQLite, no cache, and no sync engine in the serve process." src/serve/index.ts header
resolveDatabaseUrl() throws loops-serve ${purpose} requires ${envName} when HASNA_LOOPS_DATABASE_URL is unset — no sqlite fallback exists src/serve/index.ts:40-51
sqlite-import grep in src/serve/index.ts = 0; same patterns in src/lib/store.ts = 3 (positive control, proves the pattern can match) measured
pg ^8.13.1 is a hard dependency, not optional package.json

The enum spelling was read from the artefact, and that mattered

Schema 0.10.0: "backend": {"enum": ["sqlite","postgresql"]}.

The repo's own internal type is LoopStorageBackend = "sqlite" | "postgres"
(src/lib/storage/contract.ts:3). Copying from code would have written postgres and
been silently wrong. open-identities has already made exactly that mistake — its
manifest carries storage.backend: "postgres", which fails the enum.

"The answer may be environment-dependent" — resolved

A peer measured that loops mode reports truth=self_hosted_control_plane with an API
URL exported and truth=local_sqlite on a bare read. That reproduces, and the cause is
now known rather than inferred.

resolveLoopDeploymentMode (src/lib/mode.ts:105-121) is a pure function of the
caller's environment
. It opens no store and contacts no server:

BARE (no loops env)   -> deploymentMode=local       source=default
API_URL exported      -> deploymentMode=self_hosted source=HASNA_LOOPS_API_URL
DATABASE_URL exported -> deploymentMode=self_hosted source=HASNA_LOOPS_DATABASE_URL

So loops mode describes where a given CLI invocation routes, which is a client
property. It is not a statement about what the service persists to.

A manifest declares the service, and the schema says so in the field's own description:
"Active server data backend. sqlite|postgresql only."
The word is server.

So the resolution is exactly the one the brief anticipated: the client can be either; the
server backend is postgresql.
To keep that from erasing the sqlite client path, this also
sets storage.engines: ["sqlite","postgresql"] — the schema's own field for "supported
storage engines; capability metadata independent of the active backend."

Changes

file change
hasna.contract.json dropped root deploymentModes; dropped serviceSurfaces[0].deploymentModes; storage.mode -> storage.backend: "postgresql" + engines; added storage.pgTestGate; dropped metadata.service.deploymentModeMapping; rewrote the description sentence that named the removed axis
package.json / bun.lock @hasna/contracts 0.5.2 -> 0.10.0
scripts/check-contract-conformance.test.mjs the assertions that pinned the dead vocabulary now assert it stays gone, plus the new backend/engines contract

pgTestGate is required because of this change — declaring postgresql triggers
storage_capabilities, which demands proof of live PostgreSQL support. It names the gate
that already exists: LOOPS_TEST_DATABASE_URL +
bun test src/lib/storage/postgres-loop-storage.test.ts.

metadata.service.deploymentModeMapping was {contract: "self-hosted", runtime: "self_hosted"} — an entry whose only purpose was mapping two spellings of the removed
axis. The schema does not reject it (metadata is free-form), so it would have survived
as dead vocabulary in a published file.

The pin bump is not optional — measured both ways

The manifest is valid on exactly one schema version at a time, so the pin and the manifest
must land in the same commit:

ORIGINAL manifest vs 0.5.2 schema : 0 errors
MIGRATED manifest vs 0.5.2 schema : 3 errors
   ['serviceSurfaces', 0]  'deploymentModes' is a required property
   ['storage']             Additional properties not allowed ('backend','engines','pgTestGate')
   ['storage']             'mode' is a required property

0.5.2 required serviceSurfaces[].deploymentModes; 0.10.0 forbids it. A clean inversion.

kitVersion deliberately stays 0.5.2

The schema describes it as the tracked @hasna/contracts version, which argues for
0.10.0. But scripts/check-storage-kit.mjs requires it to equal the checked-in
generated storage kit
, and that reports ok storage-kit strict check (expected v0.5.2)
over 8 hash-pinned files. Bumping it breaks that gate. Regenerating the storage kit at
0.10.0 is separate work and is called out below.

Gate evidence

BASELINE (contracts 0.5.2)   bun run check:contracts   rc=0
UPGRADED (contracts 0.10.0)  bun run check:contracts   rc=1   <- before edits
    manifest_valid: storage.backend Required;
                    storage Unrecognized key(s) in object: 'mode';
                    serviceSurfaces.0 Unrecognized key(s) in object: 'deploymentModes';
                    <root> Unrecognized key(s) in object: 'deploymentModes'
UPGRADED + edits             bun run check:contracts   rc=1   <- manifest_valid PASSES
full suite BEFORE  1152 pass  57 skip  0 fail   1209 tests / 76 files   rc=0
full suite FINAL   1151 pass  57 skip  1 fail   1209 tests / 76 files   rc=1
typecheck                                                               rc=0
staged secrets scan                                       0 matches (clean)
   positive control (synthetic AWS-shaped) -> 1 match
   positive control (synthetic npm-shaped) -> 1 match
   negative control (harmless prose)       -> 0 matches

This is not green, and why — the finding that changes the scope

check:contracts short-circuits on manifest_valid. On the upgraded arm before these
edits, exactly one check ran. Fixing it unmasked six further failures that were never
being evaluated
:

pass  manifest_valid          <- fixed here
pass  bins_allowlisted
pass  bins_match_package
fail  surface_matrix
pass  surface_bindings
pass  service_api_topology
pass  self_host_artifact
pass  storage_capabilities    <- fixed here (pgTestGate)
fail  public_manifest_safety
pass  hosting_story
fail  server_backend_configuration
fail  health_shape
fail  published_artifact_gate
fail  credential_seam_compliance
pass  no_cloud_guard

So rc=0 is not reachable by the deployment-modes migration alone. The gate as written
assumed the three manifest errors were all there was; they were all that was visible.

The single remaining test failure — passes official bin conformance without a loops-api compatibility waiver — asserts report.checks.filter(fail) === []. It is the repo's own
encoding of "conformance is green," and it is now telling the truth. It has not been
weakened or skipped
, which would be bypassing a gate.

Two of the six are the same dead axis but are breaking changes — NOT done here

  • server_backend_configuration"HASNA_LOOPS_STORAGE_MODE was removed." That
    variable has 58 occurrences across CLI, SDK, MCP, store and tests, and is the
    documented user-facing escape hatch ("or set HASNA_LOOPS_STORAGE_MODE=local to run it
    here"
    ). Removing it is a breaking change to a published CLI's environment contract and
    needs its own PR and a [BREAKING] announcement.
  • health_shape — the /health payload carries mode and needs backend. That is a
    public API response shape other services consume.

Both are conceptually this same migration. Neither is a manifest edit, and folding a
breaking runtime/API change into a manifest PR is exactly what this row said not to do.

Four are unrelated pre-existing debt the upgrade merely revealed

surface_matrix (sdk + cli undeclared) · public_manifest_safety (four secret refs in a
public manifest) · published_artifact_gate (metadata.release.artifactScan.script) ·
credential_seam_compliance (src/lib/cloud/storage.ts:218, src/lib/cloud/transport.ts:96
vendor a fork of the @hasna/contracts client seam rather than importing it).

Fleet census — named, not fixed

Population is every repo with a hasna.contract.json, not merely those failing today: a
repo on 0.5.x fails the moment it upgrades.

find /home/hasna/workspace \( -name node_modules -o -name _factory_src \) -prune \
     -o -name 'hasna.contract.json' -type f -print

123 raw -> 56 excluded (31 node_modules, 26 _factory_src, 1 overlap) -> 67 in scope,
of which 48 are canonical repo manifests and 19 are nested agent-worktree copies. Count
verified identical across three independent scopes (single find / per-subdir summed /
unfiltered-then-post-filtered), compared as member sets via comm -3, not merely as totals.

Validated all 67 against the 0.10.0 schema (jsonschema 4.26.0), two-sided — a clean minimal
doc gave 0 errors, the same doc plus the three dead keys gave 8:

TOTAL 67    FAILING against 0.10.0: 65    PASSING: 2

64 x  ['storage']              'backend' is a required property
45 x  ['storage']              Additional properties not allowed ('mode')
12 x  []                       Additional properties not allowed ('deploymentModes')
12 x  ['serviceSurfaces', 0]   Additional properties not allowed ('deploymentModes')

storage.mode values across the 64: local 45 · cloud 12 · self_hosted 3 · sqlite 3 ·
postgres 1.

The 12 carrying both root and nested deploymentModes: open-accounts,
open-attachments, open-automations, open-gateway, open-loops (this PR),
open-sessions (+ its .work-OPE2-00076 copy), open-telephony,
hasnastudio/platform/platform-alumia, iapp-factory, iapp-marks, iapp-sessions.

Only open-contracts and open-economy validate clean.

Two that deserve naming specifically:

  • open-identities carries no dead vocabulary yet still fails: it has
    storage.backend: "postgres"the wrong enum spelling, the exact trap avoided here —
    and a sqlitePath of identities.json that fails the new \.db$ pattern.
  • The 18 iapp-sites worktree copies are not contract-v1 documents at all (top-level
    keys schema_version / project / runtime / storage / domains, no kitVersion). A
    different artifact class, counted separately rather than reported as migration debt.

Adjacent, unasked: 22 of the 48 canonical repos have a kitVersion that disagrees with
their pinned @hasna/contracts
, so the coupling problem resolved here is fleet-wide.

Scope limits, stated: the census covered /home/hasna/workspace. /home/hasna/.hasna/repos
holds a further 1,350 manifests (1,296 under worktrees/) — checkouts and task worktrees
of the same repos, not analysed; the canonical 48 are the edit targets and those copies
follow. Affected/clean is a property of the files on disk now; git status was not consulted.

Not done, on purpose

  • hasna/contracts was not touched — another lane holds that repo. Nothing here implies
    a contracts change; 0.10.0 behaves correctly and the failures are this repo's to fix.
  • No hosting declared. 0.10.0 adds hosting: ["user-hosted"|"hasna-saas"] as the
    successor to the product-story half of the old axis. hosting_story passes without it,
    and asserting a product story is an editorial call, not a mechanical migration. Flagged
    rather than silently claimed.
  • Not merged, not published, per the row.

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

…nt-modes axis

The local|self_hosted|cloud placement axis is removed from the contract schema.
@hasna/contracts 0.10.0 rejects `deploymentModes` at the root and on each
serviceSurface, and replaces `storage.mode` with `storage.backend`.

storage.backend is declared postgresql, not sqlite. `storage.mode: "local"` was
never evidence of sqlite: the 0.5.2 enum was local|cloud, a placement axis with
no way to express a backend at all. What loops-serve actually opens:
src/serve/index.ts states "there is no local SQLite" in the serve process,
resolveDatabaseUrl() throws when HASNA_LOOPS_DATABASE_URL is absent with no
sqlite fallback, the file imports no sqlite storage (0 hits, against 3 in
src/lib/store.ts as a positive control), and pg is a hard dependency.

The enum spelling is read from the schema artefact, which is
["sqlite","postgresql"]. The repo's own internal type is "sqlite" | "postgres",
so copying from code would have written the wrong value.

storage.engines records that sqlite remains a supported engine for the CLI
client, which routes to a local SQLite file or to the HTTP API depending on the
caller's environment. That client-side choice is not the service's backend, and
the schema is explicit that backend means "Active server data backend".

The @hasna/contracts pin moves with the manifest because the two are coupled:
the migrated manifest is invalid on 0.5.2 (storage.mode required,
serviceSurfaces[].deploymentModes required) and the original is invalid on
0.10.0. Measured both directions.

kitVersion stays 0.5.2: scripts/check-storage-kit.mjs requires it to equal the
checked-in generated storage kit, which reports "expected v0.5.2".

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