Skip to content

refactor(storage)!: remove deployment-mode branching — collapse local|hybrid|remote to a sqlite|postgresql backend - #15

Merged
andrei-hasna merged 1 commit into
mainfrom
chore/remove-deployment-modes
Jul 29, 2026
Merged

refactor(storage)!: remove deployment-mode branching — collapse local|hybrid|remote to a sqlite|postgresql backend#15
andrei-hasna merged 1 commit into
mainfrom
chore/remove-deployment-modes

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Removes the three-way deployment-mode branching from open-hooks per the owner directive
directives/2026-07-29-owner-directive-remove-deployment-modes.md. Do not merge without an
adversarial review pass.
No @hasna/contracts bump; no npm publish.

Enumerated surface — before and after

open-hooks was never scoped by this programme (not in the directive's table, not in the
design knowledge k_ms3e6v41_zbe7m8 scope list, which classes hooks-like local CLIs as N/A).
Two corrections to the scoping it was given:

  • hasna.contract.json does not exist in this repo. There is no manifest surface, no
    @hasna/contracts dependency, and no vendored storage-kit. 100% of the branching here was
    hand-rolled and repo-local
    — which answers directive §7's open question for this repo.
  • The scan was limited to src/, so it missed README.md; and the pattern used
    double-quoted "hybrid" with no bare remote, so README.md:112 — where remote sat alone
    on a wrapped line — was invisible. A real line-wrapping false negative.

Verified before: 5 files / 25 lines. After: 0 live mode surfaces. Every remaining
occurrence of the retired words is the rejection machinery, its tests, or documentation of the
retirement.

surface before after
src/db/storage-sync.ts StorageMode = "local" | "hybrid" | "remote"; HOOKS_STORAGE_MODE_ENV; HOOKS_STORAGE_MODE_FALLBACK_ENV; STORAGE_MODE_ENV; StorageStatus.mode; normalizeStorageMode (silent undefined); getStorageMode (silent fallback) StorageBackend = "sqlite" | "postgresql"; STORAGE_BACKENDS; HOOKS_STORAGE_BACKEND_ENV; STORAGE_BACKEND_ENV; StorageStatus.backend; getStorageBackend (throws); RETIRED_STORAGE_MODE_ENV rejection table
src/storage.ts, src/index.ts re-export the mode symbols + StorageMode type re-export the backend symbols + StorageBackend type
src/cli/index.tsx:1230 Mode: ${status.mode} Backend: ${status.backend}
src/storage.test.ts asserts the 3 mode values and the silent-fallback-on-invalid behaviour asserts the 2 backends, both rejection paths, and the absence of the removed exports
README.md:110-112, 117 documents the mode vars and their values; "does not require a deployed cloud or self-hosted runtime" documents the backend switch, the retirement and the mapping; runtime section restated without the 3-placement taxonomy

Words kept, because they are not the concept

76 occurrences of remote; 71 are legitimate and untouchedgit remote (in
hook-permissionguard, hook-gitguard, worktree-guard, codewith-native-common), "remote code
execution" rule descriptions, ssh host '…' remote-command parsing, and remote as the local
variable/filename for the Postgres connection (a location word the directive explicitly keeps;
not renamed, to avoid a gratuitous public-export path change). local is kept throughout. The
single cloud (README:117) was a mode surface and is rewritten. CHANGELOG.md's existing
"Remote layers…" and repos-lookup entries are historical record and left byte-identical.

The actual defect, and what replaces it

Nothing in this repo ever branched on the mode. getStorageMode() had exactly two callers —
getStorageStatus() and its own test — so the enum bought vocabulary and no behaviour. The
defect was underneath: normalizeStorageMode returned undefined for anything it did not
recognise, and getStorageMode then fell through to
getStorageDatabaseUrl() ? "hybrid" : "local".

Measured on origin/main (worktree at origin/main, unpiped rc):

HASNA_HOOKS_STORAGE_MODE=hybrid       -> rc=0, "Mode:       hybrid"
HASNA_HOOKS_STORAGE_MODE=self_hosted  -> rc=0, "Mode:       local"    <- silently normalised
HASNA_HOOKS_STORAGE_MODE=mysql        -> rc=0, "Mode:       local"    <- typo indistinguishable

A typo and the fleet's own canonical mode word were both silently turned into a mode the
operator never asked for, then reported back as if configured. On this branch:

hooks storage status                              -> rc=0, "Backend:    sqlite"
HASNA_HOOKS_DATABASE_URL=... storage status --json -> rc=0, "backend": "postgresql"
HASNA_HOOKS_STORAGE_MODE=hybrid                   -> rc=1, names HASNA_HOOKS_STORAGE_BACKEND
                                                      + "hybrid maps to postgresql"
HASNA_HOOKS_STORAGE_BACKEND=self_hosted           -> rc=1, "names a retired deployment mode …
                                                      Set …=postgresql instead"
HASNA_HOOKS_STORAGE_BACKEND=mysql                 -> rc=1, "not a known hooks storage backend"

Deleting the words while keeping the silent fall-through would have removed the symptom and kept
the hole, so the retired names are rejected with the fix named, not merely absent.

localsqlite; hybrid / remote / self_hosted / self-hosted / cloudpostgresql.
sqlite3, postgres and pg are accepted aliases of the two canonical values.

Behaviour preservation

Inference is unchanged: with the variable unset, a configured database URL yields postgresql
(previously reported as hybrid) and its absence yields sqlite (previously local). Nothing
routed on the mode, so no routing changed.

Hook evaluation is untouched. No hook, and no part of the prompt path, reads the backend —
getStorageBackend() is reachable only from getStorageStatus() (hooks storage status and the
storage_status MCP tool). This matters because hooks run on every agent prompt on this fleet.

One behaviour deliberately not preserved, called out rather than hidden: StorageStatus.mode
is renamed to .backend and the mode symbols are unexported, which is breaking for an API
consumer. Keeping the field name while changing what its values mean would let a consumer testing
mode === "local" fail silently; the rename makes it a compile error or an obvious absence. A
workspace-wide scan found no importer of @hasna/hooks outside this repo. CHANGELOG.md
carries the full before/after under a ### Changed BREAKING entry.

Owner ruling already satisfied, nothing to remove: there is no client-side PostgresStore.
PgAdapterAsync (src/db/remote-storage.ts, 40 lines) is reached only via getStoragePg(), used
only by the operator-invoked hooks storage push|pull|sync. The live data layer is bun:sqlite
only.

Verification

Base origin/main = 8d9b6ac. All exit codes measured unpiped (cmd > log 2>&1; rc=$?).

Tests failed first. src/storage.test.ts was rewritten before the implementation existed:
rc=1, SyntaxError: Export named 'HOOKS_STORAGE_BACKEND_ENV' not found. After implementing:
rc=0, 18 pass / 0 fail (up from 3 tests; adds both rejection paths, each retired value and
each retired env var as a test.each case, the alias set, and an assertion that the removed
exports are gone from the module surface).

Typecheck: bunx tsc --noEmit rc=0; bunx tsc -p tsconfig.build.json --noEmit rc=0;
bun run build rc=0.

Baseline established before attributing any failure — the suite is already red on
origin/main.
Full suite on unmodified origin/main: rc=1, 1025 pass / 13 fail. On this
branch: rc=1, 1032 pass / 18 fail. Zero storage-related failures in either. Every failure in
both runs is the subprocess-timeout flake family (this test timed out after 5000ms, plus
killed N dangling process cascades) in the CLI install/update E2E, destructive-shell-guard and
MCP-transport suites. Proven pre-existing rather than assumed: the working tree was git stashed
back to clean origin/main and the four affected test files re-run — rc=1, 8 failures,
including codewith native common helpers > managedWorktreeInfo … accepts ordinary directory names, one of the names that had looked new, and a different member of the same
parameterised E2E family (Stop hook E2E where the branch run hit PreToolUse hook E2E). The
failing set is unstable run-to-run on unchanged code.

published ≠ installed ≠ running — checked, and there is no gap here: repo main 0.5.0,
npm @hasna/hooks 0.5.0, installed CLI 0.5.0. The running installed artifact reproduces the
defect (HASNA_HOOKS_STORAGE_MODE=self_hosted hooks storage statusrc=0, Mode: local), so
merging and publishing this will change what runs on the fleet.

Positive controls, because a grep returning 0 is not evidence: the surface pattern was
confirmed to detect a planted deploymentMode: "self_hosted" in a git intent-added file (rc=0)
before its 0-counts were trusted, and the staged secrets scanner was confirmed to fire on a
planted sk-ant- value before the clean result over 483 staged diff lines was accepted. No
Co-Authored-By trailer.

Further unscoped surfaces noticed — reported, not touched

14 iapp-* internal apps carry the same copy-pasted _STORAGE_MODE pattern and appear in no
scope list
(the directive and the design knowledge enumerate only open-* repos plus
hasna/contracts): iapp-conversations, iapp-deployment, iapp-domains, iapp-files,
iapp-identities, iapp-knowledge, iapp-marks, iapp-mementos, iapp-projects,
iapp-secrets, iapp-sessions, iapp-skills, iapp-telephony, iapp-todos. Several
(iapp-projects, iapp-todos, iapp-mementos, iapp-knowledge) carry a storage-sync.ts
structurally identical to this repo's, which is where the silent-normalisation defect lives — so
the same fix likely applies verbatim. iapp-knowledge additionally ships a built dist/ with
the vocabulary baked in. This is the same "missed repos it never examined" direction the directive
warns about, one directory tree over.


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

…ith a sqlite|postgresql backend

Deployment location was never a property of the data layer. `StorageMode` named
three places something could run; nothing in this repo ever branched on it. Its
only two readers were `getStorageStatus()` and its own test, so the enum bought
vocabulary and no behaviour.

The real defect was underneath it. `normalizeStorageMode` returned `undefined`
for any value it did not recognise, and `getStorageMode` then fell through to
`getStorageDatabaseUrl() ? "hybrid" : "local"`. Measured on origin/main:

  HASNA_HOOKS_STORAGE_MODE=self_hosted  ->  rc=0, "Mode: local"
  HASNA_HOOKS_STORAGE_MODE=mysql        ->  rc=0, "Mode: local"

A typo and the fleet's own canonical mode word were both silently normalised
into a mode the operator did not ask for, and reported back as if configured.

Now:

- `StorageBackend = "sqlite" | "postgresql"` replaces `StorageMode`. `local`
  collapses to sqlite; hybrid, remote, self_hosted, self-hosted and cloud all
  collapse to postgresql, because they only ever differed in who operated the
  server.
- `HASNA_HOOKS_STORAGE_BACKEND` (fallback `HOOKS_STORAGE_BACKEND`) selects it.
  `sqlite3`, `postgres` and `pg` are accepted aliases. Any other value throws
  and names the accepted set.
- `HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE` are no longer read.
  Setting either throws, naming the replacement variable and the backend that
  value maps to, rather than being ignored. Deleting the words while keeping the
  silent fall-through would have removed the symptom and kept the hole.
- Inference is unchanged: unset plus a database URL yields postgresql
  (previously reported `hybrid`); unset with no URL yields sqlite (`local`).

Breaking for API consumers: `StorageStatus.mode` becomes `.backend`, the CLI
prints `Backend:`, and `StorageMode` / `getStorageMode` / `HOOKS_STORAGE_MODE_ENV`
/ `HOOKS_STORAGE_MODE_FALLBACK_ENV` / `STORAGE_MODE_ENV` are unexported. A
workspace scan found no importer of `@hasna/hooks` outside this repo, and the
rename makes a stale reading of the field a compile error rather than a silent
mismatch, since the values changed meaning too.

Hook evaluation is untouched: no hook and no part of the prompt path reads the
backend.

No client-side PostgresStore is introduced or retained. `PgAdapterAsync` remains
reachable only from the operator-invoked `hooks storage push|pull|sync`
commands; the live data layer is bun:sqlite only.
@andrei-hasna
andrei-hasna merged commit fafb395 into main Jul 29, 2026
2 checks passed
@andrei-hasna
andrei-hasna deleted the chore/remove-deployment-modes branch July 29, 2026 13:01
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