fix(contracts): migrate the service manifest off the removed deployment-modes axis - #200
Draft
andrei-hasna wants to merge 1 commit into
Draft
fix(contracts): migrate the service manifest off the removed deployment-modes axis#200andrei-hasna wants to merge 1 commit into
andrei-hasna wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates the published service contract manifest off the removed
local|self_hosted|cloudplacement axis, and moves the@hasna/contractspin to0.10.0because the two are coupled.Opened as a draft.
check:contractsis 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-32fa6839cb79The 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 inserviceSurfaces[0].bin— actually opens:src/serve/index.tsheaderresolveDatabaseUrl()throwsloops-serve ${purpose} requires ${envName}whenHASNA_LOOPS_DATABASE_URLis unset — no sqlite fallback existssrc/serve/index.ts:40-51src/serve/index.ts= 0; same patterns insrc/lib/store.ts= 3 (positive control, proves the pattern can match)pg^8.13.1is a hard dependency, not optionalpackage.jsonThe 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 writtenpostgresandbeen silently wrong.
open-identitieshas already made exactly that mistake — itsmanifest carries
storage.backend: "postgres", which fails the enum."The answer may be environment-dependent" — resolved
A peer measured that
loops modereportstruth=self_hosted_control_planewith an APIURL exported and
truth=local_sqliteon a bare read. That reproduces, and the cause isnow known rather than inferred.
resolveLoopDeploymentMode(src/lib/mode.ts:105-121) is a pure function of thecaller's environment. It opens no store and contacts no server:
So
loops modedescribes where a given CLI invocation routes, which is a clientproperty. 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 "supportedstorage engines; capability metadata independent of the active backend."
Changes
hasna.contract.jsondeploymentModes; droppedserviceSurfaces[0].deploymentModes;storage.mode->storage.backend: "postgresql"+engines; addedstorage.pgTestGate; droppedmetadata.service.deploymentModeMapping; rewrote thedescriptionsentence that named the removed axispackage.json/bun.lock@hasna/contracts0.5.2->0.10.0scripts/check-contract-conformance.test.mjspgTestGateis required because of this change — declaringpostgresqltriggersstorage_capabilities, which demands proof of live PostgreSQL support. It names the gatethat already exists:
LOOPS_TEST_DATABASE_URL+bun test src/lib/storage/postgres-loop-storage.test.ts.metadata.service.deploymentModeMappingwas{contract: "self-hosted", runtime: "self_hosted"}— an entry whose only purpose was mapping two spellings of the removedaxis. The schema does not reject it (
metadatais free-form), so it would have survivedas 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:
0.5.2 required
serviceSurfaces[].deploymentModes; 0.10.0 forbids it. A clean inversion.kitVersiondeliberately stays0.5.2The schema describes it as the tracked
@hasna/contractsversion, which argues for0.10.0. Butscripts/check-storage-kit.mjsrequires it to equal the checked-ingenerated 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
This is not green, and why — the finding that changes the scope
check:contractsshort-circuits onmanifest_valid. On the upgraded arm before theseedits, exactly one check ran. Fixing it unmasked six further failures that were never
being evaluated:
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— assertsreport.checks.filter(fail) === []. It is the repo's ownencoding 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." Thatvariable 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/healthpayload carriesmodeand needsbackend. That is apublic 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 apublic manifest) ·
published_artifact_gate(metadata.release.artifactScan.script) ·credential_seam_compliance(src/lib/cloud/storage.ts:218,src/lib/cloud/transport.ts:96vendor a fork of the
@hasna/contractsclient seam rather than importing it).Fleet census — named, not fixed
Population is every repo with a
hasna.contract.json, not merely those failing today: arepo on 0.5.x fails the moment it upgrades.
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:
storage.modevalues across the 64:local45 ·cloud12 ·self_hosted3 ·sqlite3 ·postgres1.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-00076copy),open-telephony,hasnastudio/platform/platform-alumia,iapp-factory,iapp-marks,iapp-sessions.Only
open-contractsandopen-economyvalidate clean.Two that deserve naming specifically:
open-identitiescarries no dead vocabulary yet still fails: it hasstorage.backend: "postgres"— the wrong enum spelling, the exact trap avoided here —and a
sqlitePathofidentities.jsonthat fails the new\.db$pattern.iapp-sitesworktree copies are not contract-v1 documents at all (top-levelkeys
schema_version / project / runtime / storage / domains, nokitVersion). Adifferent artifact class, counted separately rather than reported as migration debt.
Adjacent, unasked: 22 of the 48 canonical repos have a
kitVersionthat disagrees withtheir pinned
@hasna/contracts, so the coupling problem resolved here is fleet-wide.Scope limits, stated: the census covered
/home/hasna/workspace./home/hasna/.hasna/reposholds a further 1,350 manifests (1,296 under
worktrees/) — checkouts and task worktreesof 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/contractswas not touched — another lane holds that repo. Nothing here impliesa contracts change; 0.10.0 behaves correctly and the failures are this repo's to fix.
hostingdeclared. 0.10.0 addshosting: ["user-hosted"|"hasna-saas"]as thesuccessor to the product-story half of the old axis.
hosting_storypasses without it,and asserting a product story is an editorial call, not a mechanical migration. Flagged
rather than silently claimed.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.