Skip to content

feat: memlawb as a service, phase 2 (packaging, container, docs) - #9

Draft
beardthelion wants to merge 35 commits into
Gitlawb:mainfrom
beardthelion:feat/service-packaging
Draft

feat: memlawb as a service, phase 2 (packaging, container, docs)#9
beardthelion wants to merge 35 commits into
Gitlawb:mainfrom
beardthelion:feat/service-packaging

Conversation

@beardthelion

Copy link
Copy Markdown
Contributor

Phase 2 of memlawb as a service: packaging, the container floor, and a documentation truth pass.

Stacked on #8, which is stacked on #7. Neither parent branch is in this repo, so this PR cannot target one and is opened against main. It therefore shows all three phases. The 5 commits from feat(build): publish something Node can actually run onward are the ones to review here. Draft until the parents land, after which this rebases and comes out of draft.

Why this phase existed

The package could not be used by a Node consumer at all, and nothing said so. exports pointed at TypeScript source, and Node refuses to strip types inside node_modules:

ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING

That is deliberate policy with no flag, so the only fix is to emit JavaScript. The bin was worse: #!/usr/bin/env bun on a machine without Bun fails with /usr/bin/env: 'bun': No such file or directory, exit 127. The whole suite was green throughout, because everything in-repo runs from source under Bun.

What landed

A build producing Node output plus declarations, an exports map with a bun condition so Bun still resolves source, standalone binaries for seven targets with checksums, and a container base that meets the engine floor it declares.

A packed-tarball job is now the packaging test: it packs, installs into a scratch directory, and drives the result on a PATH with no Bun, including a real save and recall over MCP stdio through the installed binary and the block memlawb setup prints used unedited.

One bug in three costumes

src/mcp/guide.ts locates the memory protocol by walking up from its own module path and silently falls back to a short inline copy. Every packaged form breaks that path, and the symptom is always nothing: save and recall keep working.

  • The Node bundle served the fallback until inlining was added.
  • The compiled binary then did the same: 1153 bytes of fallback against 5633 of guide.
  • The image never copied skills/ at all, which does not matter today because it only runs the server.

The inliner now lives in its own module and both build paths use it. It refuses rather than degrades if the slot it substitutes into ever drifts.

Verified by running, not reasoning

  • The image builds, reports bun 1.2.23 against the declared 1.2.0 floor, serves health, round-trips an encrypted push and pull byte-identically, and holds no plaintext on disk.
  • The published package installs and works under Node 20, the declared floor, which this machine could not otherwise exercise.
  • The standalone binary drives a real save and recall over MCP stdio from a container with no runtime installed, serving the real guide.
  • Every command the README documents runs as written on a clean path.
  • The tarball script was shown to fail: a build-less tarball, a renamed bin, and a build inlining the wrong guide text are each caught.

Running the binaries also disproved something this PR had already written down. The glibc builds do run on a stock debian:12-slim with nothing installed; the -musl builds link against libstdc++ and libgcc and fail on bare Alpine until apk add libstdc++. Corrected rather than quietly dropped, because it builds and checksums identically either way.

Also fixed

src/mcp/server.ts imported zod while only the MCP SDK was declared. It resolved by hoisting, so it worked here and would break for a consumer on a stricter installer, or the day the SDK drops it, with every gate here still green. A test now walks shipped source and asserts every bare import is declared.

PLAN.md described the sync API as an openclaude drop-in that works by changing a base URL. There is no such integration, so the claim named a capability nobody could use.

Not verified here

The darwin and windows targets cross-compile but cannot be executed on this machine, and there is no qemu binfmt for the x64 Linux targets, so those rest on CI. The >=20 Node floor is now proven; the upper end is whatever CI runs.

Post-deploy monitoring and validation

No additional runtime monitoring required: this phase changes how the package is published, not what the server does. The signals that matter are release-time rather than deploy-time, and both are now gated in CI: the packed-tarball job must pass before publish, and the release attaches binaries with a SHA256SUMS file. After the first release using this, the checks worth doing once are that npm install @gitlawb/memlawb resolves under Node on a clean machine, and that a downloaded binary's checksum matches the published file.

getStore() memoizes for the life of the process, which is correct for the
server and leaves tests no way to install a fault-injecting store or a second
driver. setStore/resetStore open that door for tests only.

The seam is production code, so the guard is that production never reaches it.
A grep for callers would be an absence claim proved by grep; the test walks the
real import graph from src/index.ts and src/mcp/server.ts instead, and carries
a positive control that the walk reached the modules it claims to cover.
Verified load-bearing: planting a reference in src/handler.ts turns that named
control red and nothing else.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Entry blobs now live at a path named by their own ciphertext hash, so an
overwrite never mutates a blob the visible manifest still points at. Commit
order is blobs, then the manifest that publishes them, then reclaim what the
new manifest no longer references. A crash before the manifest write leaves
orphans no reader can see; a crash after it leaves stale extras no reader can
see. Reads fall back to the old key-derived path, so entries written before
this need no migration.

Two consequences the sweep forced out. A corrupt manifest used to start clean
on the reasoning that the blobs survived and a re-push would rebuild it;
reclaiming blobs makes that destructive, so an unreadable manifest now refuses
the write. And deterministic ciphertext means two entries can share one blob,
so reclaim checks the live hash set before removing anything.

The sweep injects a fault at every mutating call, evidences the plant landed at
that index, and asserts the visible state is either the previous one or the new
one and never torn. Verified red on the pre-change code at three tests, green
after.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
A push may now carry, per entry key, the ciphertext hash it believes that key
holds (or null for "should not exist"). The comparison runs inside the
namespace lock against the manifest the write would actually mutate, before any
projection, and collects every disagreeing key so one round trip tells the
caller everything that moved under it. Disagreement is a 409 carrying those
conflicts.

The window this guards is the caller's own turn, which is why the base is per
entry rather than a namespace version: a single-key write should not be refused
because an unrelated key changed.

A request with no base is accepted unconditionally, so existing clients are
untouched, and the hashes view advertises the capability: without that a client
cannot tell a server that enforces this from one that ignores an unknown field,
and would report a guarantee it is not getting.

DELETE reached upsert outside the catch that maps typed errors, so a conflict
there would have surfaced as a 500. It now takes a base on the query, shape-
checked separately since the body parser never sees a DELETE.

Verified: removing the comparison turns exactly the five refusal tests red, and
every pre-existing test passes unchanged.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Whether a delete removes the bytes is a property of the store, and a client
cannot see which driver a deployment runs. So BlobStore declares it and the
server reports it where a client already looks: the hashes view and every write
response. fs and s3 erase. A store that keeps history does not, and a client
that knows can refuse a scan mode that would let a secret land somewhere it can
never be removed from.

The interface change is pinned by a @ts-expect-error on a store missing the
attribute: if the requirement is ever relaxed the directive goes unused and the
type check fails. Verified by relaxing it and watching that happen.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The health route is unauthenticated, so everything it says is public. It echoed
the store's description, which on a driver whose label carries a URL or an owner
would hand that to anyone who asks. It now returns liveness and the service name
and nothing else, pinned by an exact-equality assertion.

Reachability is still worth knowing, so it moved to startup: one write, read
back, compare and remove under a reserved prefix, before the socket binds. A
store we cannot reach now stops the process instead of answering 200 over it.
The failure detail is the error's class, never its message, because a store
error commonly carries an endpoint, a bucket and an object path, and that path
carries a namespace slug.

The disjointness control asserts the probe prefix against the paths the builders
actually produce. Asserting that the namespace validators reject it would prove
the wrong property and could not fail: tenants supply namespaces, never paths.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
An operator could not tell which account was refused or why: the only output
was a startup line and a catch-all that dumped the raw error. Every refusal now
emits one JSON line carrying timestamp, owner, code, status and route class.

The field set is an allowlist, not a denylist. On a crypto-blind server the
space of things that must never reach a log is open-ended, so a denylist only
catches what someone thought to forbid; five fields cannot carry any of it
because there is nowhere for it to go. The type has no index signature, so the
compiler enforces it too. A namespace slug is deliberately absent: it reads as
opaque but is a hash of a low-entropy namespace, so it is a stable per-tenant
identifier anyone can reverse by dictionary.

Logging happens once, where the response leaves handleRequest, so the code
recorded is the code the caller received and a future refusal branch cannot be
added without being covered. The catch-all now logs the error's class rather
than its message, because a store error carries an endpoint, a bucket and an
object path, and that path carries a slug.

resetRateLimit exists because bun shares one process across test files and a
test that exhausts a bucket would otherwise refuse requests in every later suite.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Three reviewers over the source diff. What they found:

resetRateLimit duplicated _reset, which already existed and was already used by
the rate-limit suite. Removed mine and moved the better explanation onto the one
that was there.

The blobstore module header still described one blob per entry key, which is the
layout this branch replaced, and entryPath's docstring read as the current way to
store an entry rather than the legacy one. A reader landing on either would have
written new entries the old way.

A code comment cited R12, a requirement id in docs/plans, which is in
.git/info/exclude. That reference resolves to nothing for anyone reading the
repo, so it now states the property instead.

The QuotaError and StaleBaseError mapping was duplicated across the PUT and
DELETE branches. Both reviewers verified folding it into the outer catch would
be behavior-preserving today; it goes in a helper instead, so a future read path
that threw QuotaError cannot silently answer 413 rather than 500.

Also: prev now reuses checksumsFrom rather than rebuilding it, the unreachable
half of the mutated disjunct is gone, a double branch on the same condition is
one guard, and the readManifest comment no longer claims the refusal is
write-only when reads take the same path.

One tradeoff taken deliberately. The legacy blob sweep now runs only for keys the
pre-write manifest knew, since only those can have a legacy blob. On s3 the
unguarded version was a network round trip per touched key on every write,
forever, while holding the namespace and owner locks. What is given up is
sweeping a crash-orphaned legacy blob for a key absent from the manifest, which
today is only swept in the narrow case where that key happens to be written
again. Verified the sweep is still covered: removing it turns both legacy tests
red.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Nine reviewers went at the previous commits. Three findings were real bugs and
two were my tests failing the bar I set for them.

Reclaim ran inside the commit closure, so a transient store delete after the
manifest was published turned a durable write into a 500 and skipped the owner
usage write, leaving quota under-counting. It now runs after the write is
durable and never throws: collecting garbage that is already invisible to every
reader must not fail, or roll back, a write that landed.

Reclaim was also driven from the keys a request touched, which cannot see the
orphans that matter. A write that died before publishing left blobs no later
request can name, and a delete whose collection failed removed the key from the
manifest so nothing could name its hash again. Those bytes stayed forever,
uncounted by quota, while the server advertised erasure: 'erases'. BlobStore
gains list() and reclaim now sweeps the namespace's blob directory against the
live hash set, which finds both.

Manifest hashes form storage paths now, and a manifest is parsed JSON rather
than validated input, so contentPath proves the digest is bare hex before
building a path.

A malformed percent escape threw out of handleRequest entirely, past the
envelope, the security headers and the log line, falsifying the comment saying
no refusal branch escapes coverage.

On the tests: assertConsistent compared two maps getData fills on consecutive
lines behind one guard, so its missing-blob half could not fail; it now compares
against the manifest. The corrupt-manifest control read the legacy path seed()
never writes, so it asserted null equals null. The sweep reused one namespace,
so later indices never reached a mutating call and it covered half the sequence;
it re-seeds per index and pins the count. The reclaim guard, the s3 erasure
declaration, the log's owner and route defaults, and the traversal check had no
coverage at all.

Verified by mutation rather than by claim. Seven mutations that previously left
the suite green now fail it: reclaim's live check dropped, reclaim removed, s3
erasure flipped to retains, the handler guard removed, route hardcoded, owner
default changed, and the digest check removed.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Round 2 of review, against the previous commit. Six of its seven fixes held
under mutation; two things did not.

Making contentPath throw was right for the write path and wrong for the read
one: getData called it unguarded, three lines above its own comment about
skipping rather than 500ing, so a single non-digest hash took the entire
namespace's read with it where before it skipped one entry. It now skips that
entry, with a control proving the healthy ones still read.

The store-seam walk change was inert. Following import() adds no reach today
because bin/memlawb.ts's only dynamic targets are roots the walk already had,
and the floor of 20 sat below the real count of 25, so neither half could fail.
The count is now exact and the comment no longer claims something untrue: a
dropped root turns it red.

Also from that round: reclaim resolves the store inside its try so "never
throws" is structural rather than nearly-true, its failure line names the
namespace so an operator can act on it, and its doc records what it costs and
that it is single-instance for the same reason the lock is.

s3's list had no coverage at all while the same commit asserted s3's erasure,
which is backwards for the driver the hosted service runs. It now has a fake
client proving pagination follows the continuation token and stops.

Not covered, stated rather than implied: the nsSlug field on the reclaim
failure line. That path writes to stderr directly rather than through the
injectable sink.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
One predicate now decides whether a base hash is well formed, and both write
verbs use it. Before, only DELETE checked the shape, so the same malformed value
answered 400 there and 409 on PUT: the garbage reached the manifest comparison,
never matched, and reported a conflict. A caller reading "someone wrote under
you" would re-read and retry the same bad value forever.

An unreadable manifest gets its own error and a 503 with code
manifest_unreadable, on reads as well as writes. Refusing is right, but a
generic 500 tells a caller to retry what no retry can fix and leaves an operator
unable to tell it from any other fault.

The rate limiter now runs before the refusal branches, keyed on the caller when
there is one and a shared anonymous bucket when there is not. The unknown-route
and unauthorized branches sat ahead of it, and every refusal writes a log line,
so an unauthenticated caller could turn a trivially cheap request into unbounded
log volume on the machine holding every tenant's ciphertext. Keying everything
on the shared bucket would have let that abuse throttle real accounts, which is
why the key is a named rule with its own test rather than an inline expression.

The startup probe carries a deadline. Neither adapter sets a socket timeout, so
a hung connect left startup pending forever and the failure line this module
exists to produce was never printed.

Smaller: the full view carries erasure like the other two surfaces, so a client
that only pulls can still see it; a key deleted and re-added in one request is
reported only as accepted, since naming it in both arrays tells a client
mirroring deleted to drop a file the same response stored; the probe's
byte-comparison branch, s3 list pagination, and the context defaults now have
tests; and both files that install a store override reset it in afterEach,
because bun shares one process and a failure before the inline reset leaked a
stub into every later suite.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The README's API table still advertised a `/health` field this branch removed
and a PUT body without `base`, and RELEASE-0.1 still listed the store round trip
as pending work on `/health` when it moved to a startup probe. The README is the
published contract, so a reader building a monitor against it would key on a
field the server no longer sends.

Also documents what the write path gained: the optional `base` precondition and
its 409, the `supports` and `erasure` fields, and the 503 a namespace answers
when its index cannot be parsed.

Test headers cited plan identifiers that resolve only inside docs/plans, which
is in .git/info/exclude. For anyone reading this repository those pointed at
nothing, so each header now states the property in plain English instead.

BREAKING CHANGE: `GET /health` no longer returns `store`, and a namespace whose
manifest cannot be parsed now answers 503 `manifest_unreadable` where it
previously served an empty view. Without this footer release-please would cut a
patch for a changed contract, because `bump-patch-for-minor-pre-major` is set.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Every other test drives one layer. This drives the stack the way a deployment
does: AES-GCM in the client, HTTP on a real port, the content-addressed store on
disk. It covers what layer-local tests structurally cannot, a change that is
correct in memory.ts and wrong once ciphertext, the wire format and the storage
layout have to agree on the same bytes.

Twelve cases: the push/pull/modify/delete lifecycle, client-side delta, no
plaintext or passphrase on disk under the new layout, a wrong passphrase failing
to read, the shipped client (which sends no base and reads neither supports nor
erasure) still round-tripping against a server that enforces preconditions, the
409 refusal over the wire with the competing write surviving decryptably, delete
actually removing bytes, liveness-only health, a corrupt index answering 503
rather than looking empty, and a budgeted caller getting a retry hint and
recovering.

Gaps closed alongside it. Reclaim failures go through an injectable sink like
refusals do, so the namespace they name is asserted rather than assumed; that
field is the only thing making the line actionable. The filesystem listing's
absent-directory and temp-file branches are covered, both of which reclaim hits
on ordinary writes. The server's delta short-circuit had no test at all:
removing it survived the whole suite, and it now fails against a store that
counts writes.

A rate-limited request to a memory route was logged as route 'other', because
the throttle ran before the route was classified.

Verified by mutation rather than by claim: a client that stops encrypting fails
six e2e cases including the ciphertext-at-rest walk, and disabling the
precondition, reclaim, the corrupt-index refusal, or leaking the store label
through health each fail it too. Also smoke-tested against a separately spawned
server process: two entries pushed and pulled through real encryption, one
deleted, zero plaintext on disk.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The server refuses a write whose base disagrees with the manifest, but that was
worth nothing while no client sent one. The client now tracks, per namespace,
the ciphertext hash it last saw each entry hold, and sends those as the base for
the keys a write touches.

Which read fills that map is the whole design. `push` performs its own hashes
call immediately before the PUT, so a base taken from there would be
milliseconds old and would guard a window that barely exists, while the window
that matters stayed open: the caller's own turn between reading an entry and
writing it back. Only reads the caller asked for fill the map, and push's
pre-flight read is routed around it. A namespace this client never read sends no
base at all, so a first write is unconditional.

Refusals are now a typed error carrying the server's status, code and details.
Flattening every failure into one message string left a caller unable to tell a
stale write from a bad key from a quota breach, which is exactly what an agent
needs in order to recover rather than report failure.

A 404 no longer always means an empty namespace. Only the server's own `empty`
code does; a wrong URL or something in front of the server used to reach the
caller as a successful read of nothing, which is a denial rendered as success.
Both the full read and the hashes view decide this separately, so both are
covered: a fix to one is not a fix to the other.

The client can also ask whether a server enforces the precondition at all, since
one that ignores an unknown body field accepts a stale write silently.

Verified by mutation: removing the base from push or delete, letting push's
pre-flight read fill the map, dropping either 404 guard, or losing the error
code each fail the suite.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
An agent running memlawb usually has somewhere else to put a fact too: the
host's own local session log, and a repo-shared team memory. All three fire on
the same trigger, and memlawb's guide already asks for "a stable fact (a
preference, a project decision, a convention, or feedback on how to work)",
which is the same sentence the host's own prompting acts on. With no rule, the
same fact lands in whichever system the model happened to pick that turn, and a
later recall looks in the other one.

The rule is on memlawb's side because it is the only side we can change: it
routes by what the fact is, not by which system asked first. Durable facts that
must survive across machines go here, the session log stays local, repo-shared
facts stay in the repo.

The guide also now asks for one namespace per codebase beneath the owner's
authorized subtree. One namespace for everything puts unrelated projects in the
same recall corpus, which pushes the entries a query actually wants down the
ranking, and the reader is a developer running agents against several
repositories.

The rule is deliberately in both the guide file and the inline fallback that is
served when the file cannot be read, which is exactly what makes a test for it
untrustworthy: every assertion passes whether or not the file was read, so a
broken path resolution would ship unnoticed. The control asserts on markers that
exist only in the file, as a pair (present in the guide, absent from the
fallback), so a marker leaking into the fallback fails too. Verified by pointing
the resolution at a path that does not exist: the control goes red while the
clause assertions stay green, which is the whole reason it is there.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Getting a first user configured means handing them a namespace, a URL, a key and
a passphrase in a shape their agent accepts. The passphrase is the part that
must never reach us, so the module that renders the block takes no passphrase
parameter at all: the console will call this same code in the browser, and a
parameter it could fill is the one way the secret ends up on the wire. A
deliberate expect-error line pins that absence, proven load-bearing by adding
the parameter and watching the directive itself report as unused.

The namespace is pinned to the owner's subtree, which is what makes a first save
succeed. The server grants an owner user:<owner> and its children and refuses
everything else, so the built-in user:me default is unauthorized for every
hosted user. The card also documents the per-codebase form beneath that subtree,
matching the guide.

URLs must be https unless the host is loopback, checked against the parsed
hostname rather than a substring, so localhost.attacker.com is refused. Both
directions are asserted: a validator that refused everything would pass a
one-sided test.

The passphrase is 26 characters over a 32-symbol alphabet, 130 bits. The
alphabet size divides 256, so the byte reduction is unbiased with no rejection
loop, and the test computes the bits from the exported constants rather than
pinning a number a reader cannot check.

No request-capture test: this module makes no network call, so an assertion that
the passphrase appears in no captured request is true by construction and proves
nothing about the console, which is the component that actually transmits. That
half is a console-side obligation. What is provable here is structural, so that
is what is asserted: the module imports nothing, checked fail-closed against any
surviving import token with its own positive and negative controls.

The import-graph count in the store-seam test moves 25 to 26 for this module,
which is the exact-count pin doing its job.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Every server refusal reached the agent as one string wrapping the raw JSON body,
so a stale write, a rejected key, a quota breach and a rate limit all read the
same. A model given that can report a failure but cannot recover from one, and
two enabled consumers sharing a namespace make 409 ordinary traffic rather than
an edge case.

Each status now renders its own text with its own next move. 401 says the key
cannot work and retrying will not help. 409 names the keys that changed, what
they hold now, what this write was computed against, and to re-read before
saving again. 413 says to free space. 429 says not to retry, and not in a loop,
which matters because the retry it would otherwise invite lands on a single
machine. Anything unrecognized keeps the old generic message rather than being
dressed up as something understood.

The 403 text names the subtree the key can reach and deliberately does not echo
the namespace that was refused: a denial is the one moment the caller is
provably reaching outside its own subtree, so repeating the target would feed
another owner's namespace back into the model's context. The prefix is derived
as the owner root rather than the configured namespace, because the guide and
the setup card both ask for one namespace per codebase, so the configured value
is routinely a child and naming it would understate what the key reaches.

The base a write was computed against is the one thing a refusal payload cannot
carry, since the server reports only what each key holds now. The client
attaches what it actually sent, and a base recorded as absent renders as nothing
rather than as a base that was sent.

The tools now take a structural client type so a test can drive a specific
refusal without a live server: auth mode, quota caps and the rate limiter are
all frozen at config import, so a single process cannot produce all five.

Each denial has its own marker rather than a shared distinctness check: five
generic strings wrapping five different bodies are already distinct, so a set
count passes against the code this replaces. Every branch was removed once and
the named test observed red, including the two controls that survived their
first mutation.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The manifest is cleartext, so a wrong or unexpanded passphrase still lists keys
and still saves. That first save leaves a namespace written under two keys,
after which every later pull fails GCM authentication for the correct passphrase
as well. The damage is done by the first tool call, so the check has to happen
before any tool is served and the process has to exit rather than degrade.

Six configurations are now refused, each with its own diagnostic naming the one
thing to change: an unexpanded variable reference, a missing passphrase, a
server that never answered, a rejected service key, a namespace the key does not
own, and a passphrase that cannot decrypt what is already stored. A transport
failure and a refusal are separated deliberately, because they are debugged in
completely different places.

The misexpansion check matches any ${...} in a secret-bearing value rather than
one canonical spelling. openclaude substitutes an unset reference with its own
literal text and registers the server anyway, so memlawb receives a template as
a non-empty passphrase; matching only ${MEMLAWB_PASSPHRASE} would miss every
config that named the variable something else. That check runs before anything
is sent anywhere.

An empty namespace with a wrong passphrase starts, and that is correct: nothing
exists to authenticate against, so it is indistinguishable from a first run, and
the first save is what fixes the key. The undecryptable check therefore only
runs once the read reports entries.

Startup moved off module top level so it can be driven without spawning a
process, which meant the CLI's side-effecting import had to become a call.
Left as an import, `memlawb mcp` would exit zero having served nothing.

No diagnostic echoes the passphrase or the service key. These go to stderr,
which is what a launcher captures into a log, and a message quoting the value
that failed is the natural way to write one. That is asserted across all six
refusals with a control that all six were actually exercised, since an absence
claim holds just as well over an empty list.

The refusal alone is not the property worth having: the test asserts the
namespace is still fully readable by the correct passphrase afterwards, proven
by a mutation that refuses and corrupts, which passes the first half.

The setup card's env-key guard now reads both modules, because the keys it
checks moved here while the transport stayed behind. The import-graph count
moves 26 to 27 for this module.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
push reads the namespace to work out what changed, and that read already
carries the version. When nothing had changed it threw the read away and asked
again, so re-saving a fact that had not changed, the commonest write an agent
makes, cost two round trips.

The second read was also the last place in the client that treated any 404 as
an empty namespace. Both other read paths were changed to accept only the
server's own `empty` code, precisely because a wrong URL or something in front
of the server otherwise arrives as success; this one still turned it into a
completed no-op write at version 0. Reusing the first read removes the
duplicate request and the second copy of the rule together.

Also here, three things a reader trips over rather than defects: the block
explaining why the 403 text withholds the refused namespace had drifted above a
second doc comment and bound to neither function, so the security rationale read
as if it described the helper below it; the startup preflight built the same
unmapped-status sentence verbatim in two places, which is two chances to drift;
and pull walked the entries object twice, once to decrypt and once to hash the
same bodies.

Every guard on this code was re-mutated afterwards and each still turns its own
named test red. A refactor that keeps the suite green can still leave a proof
vacuous, and the suite alone would not have said so.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Review found the guard did not guard. Three separate defects, one root: the
map of what this client has seen conflated "never read this namespace", "read
it and the key was present", and "read it and the key was absent". Presence of
the map meant read, and a missing key meant absent, so the code could not tell
ignorance from knowledge.

A client that only ever wrote never armed the precondition at all. Recording a
write returned early when no map existed, and only a read created one, so the
second and every later write went unconditional. Client A wrote into a
namespace it had never read, B overwrote the key, and A's next write silently
clobbered B. That is the lost update this whole feature exists to prevent.

A key whose blob had gone missing was locked out of every future write. A full
read drops an entry whose body cannot be served, and drops its checksum with
it, so the client saw the key as absent and asserted it must not exist. The
server disagreed and refused, permanently, and re-reading could not clear it.

The distinction that settles all of it: a hashes read enumerates the namespace
authoritatively, a full read does not. So a hashes read may assert a key is
absent, a full read may only vouch for what it actually decrypted, and a write
now seeds the map so a client's own writes arm the next one. The exception is
the server's own empty answer, which means no manifest exists, so nothing can
be hidden and a create after it can still assert absence.

Deleting a key the client knows is absent now asserts that absence rather than
going unconditional. The query form has no spelling for a null base, so it
routes through the body, which already carries one.

Three smaller things found in the same pass. A push reported every key it sent
as uploaded, including ones the server refused for an invalid key, bad base64
or size, and folded their hashes into the map, so a refusal read as success and
poisoned the next write. The error path read the response body twice, so a
non-JSON refusal always rendered empty. And an error message embedded the whole
response body unbounded, which an MCP tool then puts into a model's context, so
it is now stripped of control characters and truncated.

A decrypt failure is now its own error type. A caller could not tell a wrong
passphrase from a truncated response, and the MCP preflight was telling
operators their passphrase was wrong whenever the network hiccuped.

Every guard here was removed once and the named test observed red, including
both halves of the write fold, which review found could be deleted with the
whole suite still green.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The preflight treated "the read did not throw" as "the passphrase works". It
can also mean there was nothing to decrypt: a full read drops any entry whose
stored body is missing, so a namespace whose manifest lists entries the server
can no longer serve returned nothing, raised nothing, and started with a
passphrase that was wrong. The gate this file exists to be was passing on the
one input it was built to catch.

It now counts what actually came back. Listed entries and none served is
refused with its own diagnostic, because that is server-side drift rather than
a passphrase problem and telling an operator to change their passphrase is the
worst possible advice there. A partial return starts and warns instead: one
entry decrypting proves the key, and taking memory away over entries the key is
innocent of would punish the wrong thing.

Every failure that was not an HTTP refusal was reported as a wrong passphrase,
so a truncated body or a dropped socket sent operators to change the one value
that must not change. Following that advice after a transient failure is
exactly how a namespace ends up written under two keys, which is the corruption
this file was written to prevent. Only a real decrypt failure says so now.

An unrecognized MEMLAWB_SCAN was cast rather than checked, so a typo left the
secret scanner in no mode at all and a live credential could be encrypted and
stored without a word. It is now refused, naming the three real modes.

The undecryptable diagnostic names the entry that failed, which is
server-chosen text landing in a launcher's log, so it is stripped of control
characters first: a key carrying a newline and an escape sequence could
otherwise forge a ready line in that log.

Two additions. The server now advertises whether it enforces the write
precondition, and a deployment that does not gets a warning rather than a
refusal, since an older server is supported and memory still works against it.
Nothing had ever called that check. And the success path is tested for the
first time: it came up, wrote nothing to stdout before the transport connected,
and the ready line reached stderr.

The leak matrix now drives the two paths that interpolate server-controlled
text, which were the ones it did not cover.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The server can accept a request and still refuse an entry inside it, for an
invalid key, bad base64, or size. It says so in the response. The tool ignored
that and reported the keys it had sent, so a refused save came back as
saved "x" in ns and the model went on believing its memory had landed. That is
a denial rendered as success, which is the fourth time this branch has shipped
that shape and the reason the test double now has to be able to express a
refusal at all: it could only ever report everything as stored, which is the
hole this went through.

The three tools a model calls most had no typed denial at all. recall, search
and list still dumped the raw response body into the model's context, so the
403 that deliberately withholds another owner's namespace and the 429 that
tells the model not to retry were missing exactly where a retry loop starts.

Two texts told the model to do things it cannot. The 401 said to fix the API
key in the server configuration and start it again, which is not available to a
model and, unlike the rate-limit text, never fell back to telling the user. And
a 403 while configured for a namespace outside the user: grammar promised a
subtree the server can never grant, sending the model to retry somewhere no key
can reach. Both now name a move the model actually has.

The per-codebase namespace convention resolved to user:<owner>/<repo>, so the
comment and fixture still describing the old form are corrected.

Every branch was removed once and the named test observed red. One control was
decoration until the double got honest: it only reported a refusal when the
refused key was in the same push, so a mutation that took the wrong key from
the list still passed.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Two reviewers found the same thing independently. The guide told the model to
use user:<owner>/<repo> for a codebase while the setup card handed the operator
user:<owner>/repo/<repo>. Both surfaces shipped in the same change, each tested
against itself, so nothing caught it. A user pasting the card and an agent
following the guide would put the same repository's memory in two different
subtrees, and recall would find nothing in whichever was not used, with no
error anywhere. The card now renders the form the guide documents, pinned by a
test that reads the form out of the guide rather than restating it, and that
refuses to guess if the guide ever documents more than one.

The routing rule had the same shape of gap. Its actual tie-breaker, the
question of who needs the fact, lived only in the full guide, which a model
reaches only if it chooses to call the prompt. The instructions that are always
in context carried the three categories and stopped there, which leaves the
overlapping case exactly as ambiguous as it was before the rule existed. The
tie-breaker is now in both.

The CLI subcommand had no test at all, so it now has one, including that the
generated passphrase is printed once and never appears in the block itself.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The only read that returned ciphertext returned all of it, so proving a single
entry decrypts meant downloading everything. A namespace caps at 2000 entries
and 10 MB, and the MCP server paid that on every launch to answer a question
about one entry.

GET ?view=entry&key=<entryKey> answers with that entry's base64 ciphertext and
its checksum, byte-identical to what the full read puts under the same key, so
a client decrypts it with the code it already has. It sits inside the existing
authorization check like every other branch, and the key goes through the same
validation as every other attacker-controlled name before it reaches a path.

Two answers are deliberately distinct where it would have been easier to
collapse them. A key that does not exist in a namespace that does is its own
404, separate from the code that means the namespace itself is absent, because
clients now treat only that second code as empty and anything else as an error;
a pairwise test asserts the same key yields different codes depending only on
whether the namespace exists. And an entry the manifest names whose stored body
is gone is a 503 rather than a miss. The full read skips such an entry, which
is right when the caller still gets everything else, but with one entry in play
a skip would say the key was never written. That is the denial rendered as
success this codebase has now shipped four times.

Entries written before content addressing are read through the legacy path
first, so old data keeps working.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
No request this client makes had a timeout. A server that accepted the
connection and then said nothing left the caller waiting forever, which for the
MCP server meant a subprocess that never started and never exited, a worse
outcome than any refusal it was built to produce. Every call now gives up after
a bounded wait and raises its own error type, so a hang is distinguishable from
a refusal and from a server that is not there. The default is sized for a full
10 MB transfer on a slow link, and the knob is there because the wait is total
elapsed time rather than idle time, so a genuinely slow large transfer is cut
while still making progress.

Two per-namespace caches grew forever. Every memory tool takes the namespace as
an argument the model supplies, so a model naming many namespaces grew both for
the life of the session, and one of them holds derived key material. Both are
now bounded. Eviction costs nothing but a guarantee: the next write into an
evicted namespace is unconditional, which is the same path a namespace this
client has never read already takes, and the write after it is armed again.

A new method reads a single entry through the bounded server view. What it
records is the part worth reading: exactly one key's hash, merged into what this
client already knew, leaving the enumerated flag alone. Reading one entry is
positive knowledge about that key and nothing about any other, so it may arm the
precondition for that key and must never let a later write assert some other
key's absence. Getting that wrong is what cost a drifted key every future write
before this branch fixed it.

A 200 whose body is not that view's shape is refused rather than handed to the
decrypter, because a decrypt failure is the one error a caller is entitled to
blame on the passphrase and a misrouted server must not be able to trigger it.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Startup proved the configured passphrase could decrypt stored memory by pulling
every entry and counting what came back. Every agent session paid a whole
namespace, up to 10 MB, before its first tool call, to learn something one entry
settles.

The bounded read makes a probe possible, and the reason it was not done before
was the choice of which key. Take the first and a single drifted entry condemns
a namespace whose others are fine; take one at random and the same configuration
passes on one launch and fails on the next. So: sorted order, stopping at the
first entry that decrypts, and at most five of them. Sorted for determinism,
several because one drifted entry proves nothing about the key, capped because
the cost has to stay bounded and a namespace whose first five entries are all
unreadable is broken enough to stop for.

What that gives up, stated because it is a real loss: drift after the first
readable entry is never looked at. Drift the probe walks past on the way is
still reported, so the warning names what was seen and says plainly that
anything beyond it was not checked.

A hung server now has its own diagnostic. It had been reported as unreachable,
which sends an operator to check DNS and firewalls for a server that accepted
their connection and simply never answered.

An unexpanded service key no longer borrows the passphrase's warning about
writing memory under a key nobody can reproduce. Template text in a service key
gets a 401 and stores nothing, and pointing an operator at the wrong value while
their real problem is one line away is its own kind of failure.

The cap needed its own control. The first test only proved the probe stops
early, which holds whether or not the cap exists, because a readable first entry
ends it either way. Removing the cap survived until a test drove twenty
unreadable entries and asserted it read five.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The card is the first thing a new user gets, so a value it accepts and the
server later rejects costs them a debugging session over a field they cannot
see. Two inputs could do that.

A service URL carrying a username or password passed validation and landed
verbatim in the pasted block, putting a credential in a file the user copies
around when the block already carries the service key in its own variable.

The owner and repository strings were interpolated into a namespace unchecked.
A slash moves the segment the authorization rule matches on, and traversal or
whitespace produces something the server refuses later with a message about the
namespace rather than about the owner field that was actually wrong. Both are
now checked against an allowlist mirroring the server's own grammar, with the
rule each clause mirrors named in a comment beside it, since this module cannot
import the real one and silent drift between the two would be worse than the
duplication.

Both refuse rather than sanitize. A quietly rewritten owner is a namespace the
user did not ask for.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The fifth instance of the shape this branch keeps producing, and the one a
model acts on hardest. A namespace whose manifest names entries whose stored
bodies are gone reads back with nothing in it, exactly like a namespace nobody
has written to, and recall answered both with "no memory stored yet". A model
told its memory does not exist does not go looking for it, it starts again and
saves over what is still there.

The two are distinguishable and were not being distinguished: a namespace that
was never written is at version zero, one that has lost its bodies is not. The
read tools now say so, and say plainly not to treat it as a fresh start. Listing
is deliberately left alone, because it reads the manifest and still names the
missing keys, which is the true answer there.

An unrecognized failure could also put an unbounded response body into a model's
context, since the fallback rendered the error message and an HTTP error's
message carries the body. It is stripped of control characters and truncated.

Adds an end-to-end suite for the surfaces this phase introduced. The existing
one drives the storage round trip; this drives what a deployment exposes and
what only meets in a running process: a generated card parsed exactly as an
agent would parse it and used unedited to save and recall, the preflight
refusing a wrong passphrase and the memory surviving it, the bounded proof
counted on the wire, a stale save refused as tool text and recovered from, an
entry the server refuses not reported as saved, a namespace with its bodies
deleted from disk, and the whole flow captured through a proxy to assert no
passphrase or plaintext ever crosses it.

Six mutations were run against it and each turned a named case red. A seventh
survived twice and both survivals were defects in the test rather than the code:
the first because the per-repo namespace was never exercised, the second because
the assertion compared the card against the function the card itself calls,
which passes however both move.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The contract gained a bounded single-entry read, the CLI gained the subcommand
that generates a user's configuration, and the MCP server gained a startup
refusal and a timeout knob. None of it was written down, so the README described
a service that no longer matches the code.

The entry view's three answers are spelled out because they are the part a
client has to get right: no such namespace, no such key, and the manifest naming
a key whose body the store cannot produce are different conditions, and
collapsing them is how a caller ends up treating lost data as an empty
namespace. The write precondition is documented alongside them, including that a
write without one stays unconditional so an older client keeps working.

Verified against a running server rather than from the source: each documented
status and code is what the route actually returns.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
A background security pass on the pushed branch found the asymmetry, and it was
mine: the previous commit bounded the unrecognized-failure path and left the
typed ones alone. Those are the paths a server can actually steer, because it
picks the status that selects them.

A stale-write refusal quotes the entry keys and hashes the server named, and a
quota refusal quotes the server's own error code. All of it is read off a
response body and rendered into an AI agent's context. Newlines and escape
sequences are the sharp part rather than length: text carrying them can forge a
turn or an instruction in the conversation the tool result lands in, and a
refusal is a message the model is meant to act on.

Everything server-chosen now passes through the same gate, and a refusal may
name at most a few keys and says how many more there were. Without that, a
server answering with thousands of conflicts fills the context with one tool
result while every individual key stays short.

Each of the five guards was removed once and its named test observed red,
including the control that the key is still named, so this bounds the text
rather than dropping the detail a model needs to recover.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The package could not be used by a Node consumer at all. `exports` pointed at
TypeScript source, and Node refuses to strip types inside node_modules:

    ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
    Stripping types is currently unsupported for files under node_modules

That is deliberate policy with no flag to turn off, so the only fix is to emit
JavaScript. The bin was worse: `#!/usr/bin/env bun` on a machine without Bun
fails with `/usr/bin/env: 'bun': No such file or directory`, exit 127, and npm's
shim runs whatever shebang the target carries.

There is now a build producing Node output for the three client entries and the
bin, with declarations from a second config, and an exports map whose key order
matters more than it looks: types first or the TypeScript resolver hands back
JavaScript, then a `bun` condition pointing at source because Bun otherwise
prefers `.js` inside node_modules and the Bun path would silently run the build
instead of the source it is meant to run. No `require` key, since no CommonJS is
emitted and a missing key gives a clearer error than a broken one.

The guide needed inlining, and this is the part that would have shipped broken
in silence. `loadMemoryGuide` walks up from its own module path to find SKILL.md
and falls back to a short inline copy when the read fails, so a bundle at a
different depth serves the fallback and reports nothing. Proven by driving the
installed build's MCP server over stdio under Node and fetching the guide: 5671
bytes carrying markers that exist only in the file, against 1161 bytes of
fallback from a build with the inlining removed. The build also refuses if the
slot it substitutes into ever drifts.

`memlawb serve` needs Bun and now says so, instead of dying on an undefined
global. The client commands all run on Node.

Also declares zod, which shipped code has imported directly while only the MCP
SDK was declared. It resolved by hoisting, which is not a guarantee: a stricter
installer does not hoist, and the day the SDK drops it the import breaks for
consumers while every gate here stays green, because this repo's own install
still has it. A test now walks the shipped source and asserts every bare import
is declared, with a control proving the walk found the imports it checks.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
The suite was green while a Node consumer could not import this package at all.
It has to be: everything in-repo runs from source under Bun, so no test here can
see what the tarball contains, what the exports map resolves to on Node, or what
shebang the bin carries. That blind spot is the whole reason the packaging was
broken without anyone noticing.

This packs, installs into a scratch directory, and drives the result on a PATH
that deliberately has no Bun. It imports the package and both subpaths, runs the
bin, checks that `serve` refuses on Node while naming Bun, and speaks MCP over
stdio to the installed binary to complete a real save and a real recall against
a running server. It also parses the block `memlawb setup` prints and uses it
unedited, because every other reason a first save fails, an unreachable URL, a
rejected key, a quota, a scan mode, is invisible to a check on the namespace
string alone.

Three checks exist to catch drift rather than breakage: the tarball must contain
what the exports map names, derived from the manifest so it cannot fall behind
it; the bin name and the environment variables the card emits must be ones the
server still reads, since the consumer repos hardcode both and would otherwise
fail at spawn time in another repository; and the served guide must be the real
file, since a bundle at the wrong depth silently serves the short inline copy.

The script is only worth what it has been shown to catch, so four failures were
induced and each was caught: a tarball built without the build step, a renamed
bin, and a build that inlines the wrong guide text. The first attempt at the
renamed bin crashed the run instead of reporting it, which is why the name is
checked before anything invokes it and a missing bin now fails cleanly.

CI gains a Node job on 20 and 22. 20 is the declared engines floor and is
otherwise never exercised anywhere. The release job runs the same check before
publishing: `prepack` does build, verified with a dry run after deleting the
output, but shipping a package no consumer can install is the one failure every
in-repo gate is structurally blind to, and it cannot be taken back once a
version is on the registry.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
…hat meets the floor

The image pinned bun 1.1 while the package declared a floor of 1.2. Nothing
failed, because an engines range is a string nobody executes; it surfaces later
as a runtime feature missing on a deployment. A test now compares the two and
goes red in both directions, whether the image slips behind or the floor moves
ahead.

Standalone binaries let a machine with neither runtime use this, and building
them turned up the guide trap for the third time. `src/mcp/guide.ts` locates
SKILL.md by walking up from its own module path, so every packaged form breaks
it: the Node bundle was fixed last commit, and a compiled binary has no such
path at all. Measured: the binary served 1153 bytes of inline fallback instead
of 5633 of guide, while save and recall worked perfectly and nothing anywhere
reported a problem. The compile now reuses the same inlining, verified by
driving the binary over MCP stdio with neither bun nor node on PATH.

The inliner moved into its own module because the binary script importing the
bundle script ran the bundle as a side effect, which is its own small lesson
about build scripts with top-level work.

Bun cross-compiles every target from one runner, so the release builds all seven
in a single job rather than a matrix, and writes checksums beside them: a
downloaded binary is the one artifact a user cannot inspect before running it.

Not verified here: the image build itself. The Docker daemon is not reachable
from this environment, so the base bump rests on the version test and on CI
rather than on a build I watched succeed.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
PLAN.md called the sync API an openclaude drop-in that works "by changing a base
URL". There is no memlawb integration in openclaude, and the one that is planned
goes through the MCP tools rather than that route, so the claim named a
capability nobody could use. Corrected in all three places it appeared rather
than softened in one.

The README had no install section at all, which stopped being acceptable the
moment the package started serving Node consumers and shipping binaries. It now
says which of the three ways in a reader wants, and that `serve` needs Bun while
every client command does not.

The write precondition needed its scope stated. It reads like a property of the
product; it is a property of a client that has done a read, which means the MCP
server across a session. `memlawb push` builds a fresh client per invocation and
has read nothing, so it sends no base and its writes are unconditional. That is
by design and it is exactly the sort of thing a reader assumes the other way.

The environment example was missing every auth and S3 variable the server
actually reads, so anyone configuring multi-tenant or object storage from it
would have found the file silently incomplete. Also notes that S3 needs Bun,
since that adapter uses Bun's client and has no Node path.

The setup block is labelled a public interface, because the consumer repos point
back at it and the packaging test asserts the variables it emits are ones the
server reads, so a rename fails here rather than at spawn time elsewhere.

Verified by running every command the README documents on a clean path: the
server starts and answers health, push and pull round-trip byte-identically,
setup renders, and the three development commands pass. The two health-route
descriptions the plan flagged were already corrected in an earlier phase.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
Claimed they need nothing installed. True for the glibc builds, verified on a
stock debian:12-slim with neither Bun nor Node present. False for the musl ones:
they link against libstdc++ and libgcc, so a bare Alpine refuses to load them
with a relocation error until `apk add libstdc++`. Found by running one, which
is the only way this was ever going to surface, since it builds and checksums
identically either way.

Also verified in a container rather than argued: the image now builds, reports
bun 1.2.23 against the declared floor of 1.2.0, serves health, round-trips an
encrypted push and pull byte-identically, and holds no plaintext on its disk
(with a control proving the grep that says so can find something). The published
package installs and works under Node 20, which is the engines floor this
machine could not otherwise exercise. And the binary drives a real save and
recall over MCP stdio from a container with no runtime, serving the real guide
rather than the fallback.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
CI caught what local runs could not. The packed-tarball job failed on both Node
majors with `SyntaxError: Duplicate export of 'ciphertextHash'`, while the same
Bun version built a clean artifact here and in a container. The runner is x64
and this machine is arm64, and that is as far as chasing it is worth going.

Code splitting was the condition. It saved one copy of the crypto module across
`.` and `./crypto`, but `client/index.ts` re-exports crypto while
`client/crypto.ts` is also its own entry, and that overlap let the shared chunk
export the same name twice. Splitting is now off for the client entries: each
carries what it needs, which costs a few KB and removes a class of failure whose
appearance depended on which machine ran the build.

The more useful half is that the build now refuses to emit an artifact Node
cannot load. It imports every entry it produced and fails if any does not. A
bundler can emit a file that is valid to it and a syntax error to Node, and
shipping that is worse than failing: the tarball is produced, the checksums
match, and every gate in the repository stays green while nobody can install the
package. Verified by appending a duplicate export to the output and watching the
build stop with that exact message.

Signed-off-by: beardthelion <56458543+beardthelion@users.noreply.github.com>
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