Tracked deferred work for the eserstack project.
What: pkg/@eserstack/noskills-client dropped three DaemonEvent variants
(DeltaEvent, ToolStartEvent, ToolResultEvent). That is a breaking change
to a published package, so the next release is a major: 4.1.58 → 5.0.0.
Why this was NOT bumped alongside the change. Two reasons, both concrete:
deno task cli codebase versions majorrewrites ~43package.jsonfiles plusVERSIONand makes no git commit of its own (versions.tshas no git calls — the commit lives inrelease.ts). Run on a feature branch it rides along as unrelated churn.- A major is the one bump type the ajan platform caret ranges cannot
absorb.
pkg/@eserstack/ajan/package.jsonpins its six platform binaries at^4.1.0, andnpm/generate-packages.tsstamps those packages from the rootVERSION. Publishing them as5.0.0would no longer satisfy^4.1.0, so an install resolves a stale 4.x binary against a 5.x ABI. Pinning them exactly is NOT the fix — that is what0d5c9cbfreverted, because the platform packages are published after the tag and--frozen-lockfilethen fails on a version that does not exist yet.
Step 1 is DONE — the ranges are already widened. It was the
ordering-sensitive half and it does not have to wait for the release branch:
^4.1.0 || ^5.0.0 resolves to exactly the same 4.x today, so widening early is
inert until the bump. Three files carried a constraint, not the one named above:
pkg/@eserstack/ajan/package.json— six platform depspkg/@eserstack/cli/package.json— the same six, at mixed floors (three^4.1.57, three^4.1.0). Each floor was preserved and widened in place rather than flattened, since the^4.1.57floors may be deliberate.pkg/@eserstack/cli/scripts/npm-build.ts— the generated@eserstack/ajandependency of the published CLI.
pkg/@eserstack/codebase/ajan-ranges.test.ts now enforces the constraint: every
@eserstack/ajan* range declared anywhere in the workspace must admit the root
VERSION. That converts a silent failure into a failing test at the moment of
the bump — an optionalDependency whose range matches nothing is omitted by
pnpm rather than erroring, which is why this needed a guard and not a note.
Do at release time, on a dedicated release branch:
deno task cli codebase versions major, and commit the whole bump as one release commit.pnpm install --lockfile-only, verify withpnpm install --frozen-lockfile.
Effort: S. The ordering hazard is now enforced rather than remembered.
What: Submit the eser package to the official nixpkgs repository.
Why: Enables nix-env -iA nixpkgs.eser without needing the flake URL.
Significantly increases discoverability for Nix users.
Context: The in-repo flake.nix downloads pre-built binaries from GitHub
Releases. An upstream nixpkgs package would do the same but be maintained by the
nixpkgs community. Requires a stable release history (2-3 releases with binary
assets) and passing the nixpkgs review process.
Effort: M (mostly waiting for maintainer review)
Depends on: Stable binary distribution pipeline (shipped in current release)
What: Add Windows package manager support via Scoop manifest and/or winget manifest.
Why: The CI already compiles a Windows binary (x86_64-pc-windows-msvc).
Adding Scoop/winget makes it discoverable by Windows developers.
Context: A Scoop manifest is a JSON file in a "bucket" repo (similar to
Homebrew tap). A winget manifest requires submission to the
microsoft/winget-pkgs repository. Both are straightforward once binary
distribution is stable.
Effort: S per manager (~1 hour each)
Depends on: Stable binary distribution pipeline
What: Wire detectTensions() into the concern list CLI command so it
prints warnings when active concerns conflict (e.g. move-fast ↔ compliance).
Why: detectTensions() already exists in the codebase. The CLI plumbing is
the only missing piece. Visible warnings help developers understand trade-offs
before starting a spec.
Context: Identified during noskills UX plan review. detectTensions() is implemented but not called from the concern list output path.
Effort: XS (CC: ~30 min)
What: Add a --about flag that prints the eserstack philosophy in 3
sentences and exits.
Why: Surfaces the philosophy from the CLI without requiring a browser. Quick reminder of why noskills exists.
Effort: XS
What: Update the noskills init banner to include a one-line reference to
"Built on eserstack foundation layer."
Why: Reinforces the product identity at the first moment of use.
Effort: XS
What: When web sinks (httpResponse, webSocket) ship for @eserstack/streams, configure sensible timeout and backpressure defaults.
Why: Pipeline timeout (in MVP) handles abort, but web sinks need per-sink backpressure thresholds and configurable timeout defaults to prevent silent hangs in production.
Context: The @eserstack/streams MVP ships with stdout/buffer/null sinks (all fast). When web sinks arrive, they face real backpressure from network conditions. The pipeline timeout mechanism provides the abort, but each web sink needs sensible defaults for buffer size limits and timeout durations.
Effort: S (CC: ~15 min per sink)
Depends on: @eserstack/streams MVP (pipeline timeout)
Findings from a full-codebase audit of pkg/ajan (86k LOC Go) and
pkg/@eserstack (194k LOC TS). 85 raw findings, 64 survived adversarial
verification; the 21 refuted ones are omitted. Ordered by recommended fix
sequence, not by severity — the P0 batch is small, unconditional, and unblocks
everything else.
Status (2026-08-01): the entire P0 batch below is done, each with a regression test verified to fail against the original code. Remaining: P1, P2, P3. One new defect was surfaced while wiring the gate and is filed as P0 — Go runtime crash under the FFI library at the end of this section.
Recurring theme across every subsystem: the correct safety primitive already
exists, right next to the call site that bypasses it. sendStreamEvent exists
and 6 of 8 aifx adapters bypass it; RequestSizeLimitMiddleware exists and is
wired into zero servers; setupCancelKill exists and covers 1 of 16 spawn
sites; writeStateAndSpec exists and is used 3 times against ~39 hand-rolled
dual-writes. Most fixes below are "route the existing correct thing through the
call sites", not "build something new".
What: Deno 2.9's experimental QuickJS backend (denoland/deno#36194, merged
2026-08-04) compiles this CLI 31 MB smaller (268.0 → 236.9 MB) and then fails
every command except --help, with Top-level await promise never resolved.
The trigger is Command.lazyCommand dispatch combined with a real dynamic
import: the load promise never settles, so the handler never runs. Since the
whole command tree is lazily loaded, that is every real command.
Ruled out (each verified inside a compiled quickjs binary): dynamic import
generally, including nested in awaited async functions and of modules with their
own top-level await; the size of the imported graph — importing
cli-system/mod.ts directly and building the command from it works;
setTimeout; fetch; Deno.dlopen plus a full FFI load; @eserstack/streams
output and close(); and the async/await vs .then() shape of the loader.
Not minimised. It reproduces reliably in this repo but I could not reduce it
to a dependency-free script, which is what an upstream issue needs. Module
dispatch (modules: { x: { load: () => import(...) } }) works under quickjs
while lazyCommand does not — that difference is the most promising thread for
whoever picks this up, and it is also the workaround.
Why P3: we are not adopting the backend. 31 MB is a small win against a 268
MB binary -- for scale, dropping one oversized dependency earlier took 278 MB
off every binary -- and the failure mode is severe. Worth filing upstream once
minimised, because "call a function that dynamically imports, then .parse()
the result" is an ordinary CLI pattern and the backend is young.
Do not add --engine quickjs without testing a lazily-dispatched command
end to end; --help passing proves nothing, as it never triggers a lazy load.
Resolved. There is one installer now: etc/scripts/install.sh, taking the
product as an argument (eser, noskills, laroux, noskills-server), with
etc/scripts/install.ps1 as its Windows counterpart. Every product is built at
one version by one pipeline into one SHA256SUMS.txt, so a single script serves
all of them.
Resolved by removing GoReleaser rather than repairing it.
The diagnosis stands as history: build.yml's tag-release pushed the tag with
the default GITHUB_TOKEN, and GitHub does not dispatch workflows for events
created with that token, so release.yml — which triggered only on push: tags
— never fired. Verified against the API for v4.1.47 → v4.1.56: not one release
carried a noskills_* asset.
.goreleaser.yaml and .github/workflows/release.yml are both deleted.
pkg/@eserstack/cli/scripts/compile.ts now cross-builds noskills-server
(CGO_ENABLED=0, -s -w) alongside the deno-compiled binaries, so there is no
second workflow left to fail to trigger.
What: Investigate the intermittent
fatal error: runtime: unexpected waitm - semaphore out of sync thrown from
inside libeser_ajan during the Deno test suite.
Why: It aborts the whole test process, so deno task cli ok fails roughly 1
run in 6. It is a Go runtime fatal error, not a panic, so nothing can recover
from it — in production it would take the host process down.
Context: Pre-existing, not introduced by the P0 work. It only became
visible because the gate now builds the native library, so the *.ffi.test.ts
suites actually run for the first time (they previously failed fast with "native
library unavailable"). Verified by A/B: rebuilding the library from the
unmodified HEAD bridge.go reproduces it at the same rate (1 crash / 6 full
runs) as the current tree. Isolating just the five FFI test packages did not
reproduce it in 8 runs, so the trigger appears to involve the full suite's
concurrency — most likely Deno.dlopen of one c-shared Go library from several
parallel test isolates. Start there.
Effort: M to diagnose; unknown to fix
Done (2026-08-02). The "concurrency" hypothesis above was wrong, and so were
two others I tried (FFI callbacks re-entering Go; nonblocking: true threadpool
dispatch — A/B'd at 3/8 vs 5/8 crashes, p = 0.62, i.e. no effect).
The actual cause is sequential, not concurrent: Deno disposes a test-file
isolate, which dlcloses the library; the next file dlopens it again. A Go
runtime cannot be unloaded and reloaded — the second init runs while threads
from the first are still parked, and the scheduler dies. That is why isolating
the five FFI packages never reproduced it: one file, one load.
Fixed by making the image un-unloadable from inside itself — a cgo constructor
re-opens its own image with RTLD_LAZY|RTLD_NOLOAD|RTLD_NODELETE
(pin_image_posix.go), so dlclose drops the refcount but never unmaps.
Measured: 0 crashes / 8 full runs, against a pooled baseline of 8 / 16. Fisher
exact p = 0.022.
Caveat — not verified on Windows. pin_image_windows.go uses
GET_MODULE_HANDLE_EX_FLAG_PIN and has never been compiled (no mingw toolchain
available here). It is also not in any published npm platform binary yet; those
need a rebuild to carry the pin. See the P3 Windows item below.
What: Add a go.work covering the root module and pkg/@eserstack/ajan,
plus working-directory: pkg/@eserstack/ajan gate steps in
.eser/manifest.yml.
Why: pkg/@eserstack/ajan/go.mod declares a second module and there is no
go.work, so the root-relative go vet ./...,
go tool golangci-lint run ./... and go test -race ./... all silently skip
bridge.go (4,954 LOC), main.go, main_wasi.go — and their 1,360 LOC of
live, passing tests. Running the lint by hand surfaces 13 issues today. This
is the highest-leverage item in the audit: it makes every other Go finding in
the bridge visible to the gate instead of to auditors.
Context: .eser/manifest.yml:122 carries a comment asserting these steps
cover that directory. They do not.
Effort: S
Done (2026-08-01): go.work + explicit second pattern in the gate; 13 lint
issues -> 0; go test -race on the bridge module now runs (was never run). Two
real leaks fixed in bridge.go (missing state.cancel() on the drain path of
both codebase stream readers) and bridgeShutdown now drains all 13 handle
registries.
What: Run scripts/build.ts in the CI validate job and pin
ESER_AJAN_LIB_PATH to the freshly built artifact.
Why: validate currently type-checks and lints the entire TS half against
the previously published native binary, so an ABI change cannot fail CI in
the commit that introduces it.
Context (corrected 2026-08-04): the paragraph that used to sit here had the two packages the wrong way round, and prescribed the change that broke the release pipeline.
What is actually true: pkg/@eserstack/ajan/package.json is the one on caret
ranges (all six platform deps at ^4.1.0); pkg/@eserstack/cli/package.json is
the mixed one — three at ^4.1.57, three at ^4.1.0. The alleged
4.1.56/4.1.57 lockfile straddle is stale: every one of the twelve entries in
pnpm-lock.yaml resolves to 4.1.57 at HEAD.
More importantly, the remedy it prescribed — pinning the platform deps exactly —
was tried and reverted. See 0d5c9cbf ("fix(build): unpin ajan platform
deps to unblock frozen-lockfile"), which put all six back to ^4.1.0. The
reason is written down in pkg/@eserstack/codebase/versions.ts inside
syncAjanVersions: the platform packages are published after the version
bump, so pinning them exactly makes the lockfile reference versions that do not
exist yet and --frozen-lockfile fails.
Do not re-apply the old advice. If the ABI-coupling risk is to be addressed, it needs a different mechanism — an install-time ABI check, or publishing the platform packages before the bump — not exact pins.
Done (2026-08-01): build-native-lib step added to .eser/manifest.yml
ahead of every Deno step; scripts/build.ts pins GOWORK=off so released
artifacts still resolve from the module's own go.mod. This also fixed the
*.ffi.test.ts suites, which had been failing with "native library
unavailable".
Nothing open from this entry. An earlier revision listed "pin cli's six
platform binaries exactly and extend syncAjanVersions to cover them" as
remaining work. That is the advice corrected above: it was tried, it broke
--frozen-lockfile, and it was reverted in 0d5c9cbf. The lockfile no longer
straddles two versions either — all twelve entries resolve to 4.1.57.
What: Extract a sendLocked helper; move worker.Close() out from under
sm.mu.
Why: pkg/ajan/noskillsserverfx/worker.go:314-326 takes w.mu then calls
send() (:256-261) which takes w.mu again — sync.Mutex is not reentrant.
Reproduced hanging in 2s. It fires at sessions.go:110 while sm.mu is still
held (unlocked at :111, never reached), so every subsequent GetOrCreate,
Remove, ListBySlug and every pump goroutine's cleanup blocks forever —
permanent daemon-wide freeze, no panic, no log.
Context: Trigger is any openLedger failure: ENOSPC, read-only FS, bad
perms. Found independently by two auditors.
Effort: S
Done (2026-08-01): sendLocked extracted; worker.Close() moved out from
under sm.mu. Regression test worker_close_test.go verified to hang-and-fail
against the original.
What: Give shellfx/exec the process-group kill that shellfx/pty already
does correctly; close stdin's fd before wg.Wait() in shellfx/tui.
Why: Both hangs reproduced. pkg/ajan/shellfx/exec/exec.go:160-170 waits on
<-h.exitCh, gated behind wg.Wait(), gated behind a readerLoop parked in
r.Read — with no Setpgid and no cmd.WaitDelay, a sh -c "... &"
grandchild holds the pipe open forever. pkg/ajan/shellfx/tui/tui.go:121-125
waits on a reader parked in read(2) on raw-mode stdin. Because
EserAjanShellExecClose / EserAjanShellTuiKeypressClose are synchronous
FFI symbols, these deadlock the Deno isolate's main thread, not just the child.
Context: pkg/ajan/shellfx/pty/pty_unix.go:107-125 already signals -pid
and gets this right — the correct copy is the least-exercised one.
Effort: S
Done (2026-08-01): exec: Setpgid + process-group SIGKILL +
cmd.WaitDelay, and the pipe read ends are closed to unblock a parked Read.
tui: read-deadline interrupt plus a bounded wait so Close always returns. Both
regression tests verified to fail against the original.
What: Two-phase placeholder insert so the map lock is not held across
SpawnWorker.
Why: pkg/ajan/noskillsserverfx/worker.go:142-192 holds the global session
mutex across a 30-second SpawnWorker deadline, serialising every session
operation behind the slowest spawn.
Effort: S
Done (2026-08-01): Two-phase placeholder (sm.pending) so the spawn runs
unlocked while concurrent callers for the same key wait rather than
double-spawning.
What: Use context.WithoutCancel(ctx) in both Start methods; call
process.Shutdown() after process.Wait(); replace the WaitGroups map with
one sync.WaitGroup plus a mutex-guarded name slice.
Why: Start(ctx) returns a cleanup closure doing
context.WithTimeout(ctx, GracefulShutdownTimeout) — capturing the same ctx
whose cancellation triggers cleanup (http_service.go:182-200,
http3_service.go:171-186). Both call sites do
<-goroutineCtx.Done(); cleanup(), so the shutdown context is born already
cancelled and GracefulShutdownTimeout is dead. On the quic-go path it is worse
than a no-op: <-ctx.Done() makes it call s.Close(), force-killing live
QUIC/WebTransport sessions.
Context: Process.Shutdown() — the only thing that waits on the WaitGroups
— has zero non-test callers, so cmd/noskills-server/main.go:156 returns
into os.Exit racing its own cleanup goroutine. The
WaitGroups map[string]*sync.WaitGroup (processfx/process.go:29) is
unsynchronised and reproducibly aborts with
fatal error: concurrent map writes, which recover() cannot catch — while
processfx/README.md:658-663 explicitly documents "Safe to call
StartGoroutine concurrently". Correct the README as part of this.
Effort: S
Done (2026-08-01): context.WithoutCancel in both services;
process.Shutdown() wired into cmd/noskills-server/main.go; the racy exported
WaitGroups map replaced with an unexported sync.WaitGroup +
RunningNames(). README corrected.
What: Add drainAndClose(resp) before each retry and each early return;
call req.GetBody() in handleRetry; reset failureCount on success.
Why: Three separate defects on the same path, all measured:
httpclient/transport.go:109,117-119,139-141never closes or drains any non-final response — 15 distinct sockets for 5 all-503 client calls, zero reuse. With the package's default zero client timeout they are never reclaimed.- The retry path rejects bodies without
GetBodyand then never calls it:handleRetryendsreturn req.Clone(req.Context()), which shallow-copies the drained Body, so attempt 2 dies withContentLength=N with Body length 0and the caller sees a bogus transport error instead of the server's 503. circuit_breaker.go:81-89fast-pathsOnSuccessbefore resettingfailureCount, so it trips on lifetime-cumulative failures. Measured: open after 5 failures across 400 successes, against README text saying "5 consecutive failures". WithMaxAttempts=3, two ever-failed requests reach the threshold.
Context: Retry jitter (retry_strategy.go:36-38) is correctly implemented,
so the usual thundering-herd follow-on does not apply.
Effort: S
Done (2026-08-01): drainAndClose before every retry and early return
(measured: 5 dials -> 1 for 5 attempts); GetBody rewind in handleRetry;
failureCount reset on success. All three regression tests verified to fail
against the original.
What: Origin allowlist plus a per-process token on /mux and the mutating
REST routes. Structurally: remove command / args / cwd from the
wire-reachable Action surface so the client names a pane kind that the
resolver maps server-side.
Why: pkg/@eserstack/noskills-web/server.ts:87-92 routes /mux to
handleMuxWs with no auth, no token, no Origin check.
terminal/ws-bridge.ts:18-34 upgrades unconditionally;
mux/ipc/transport-ws.ts:33-39 does JSON.parse(ev.data) as In;
mux/server/server.ts:244-263 passes msg.action straight to applyAction;
and engine/types.ts:151-159's newTab action carries command, args,
cwd, which manager/session-binding.ts:33-46 resolves into a PTY spawn with
env: {...runtime.env.toObject()}. WebSockets are CORS-exempt, so loopback
binding is not a boundary: any page visited while noskills web runs gets
arbitrary argv with the developer's full credential environment — and since
connect() is single-viewer, it evicts the real browser and reads the terminal
stream. POST /api/action and /api/tab are equally CSRF-able (no content-type
check).
Context: Judges split on ranking (architect 6th, SRE 3rd) because exposure requires the dev server to be running. Severity is high, exploitability is conditional.
Effort: M
Done (2026-08-01): Per-process token (CSPRNG, constant-time compare) +
Origin allowlist + JSON content-type gate; token delivered to the browser via a
<meta> tag. Ingress sanitizer strips command/args/cwd from remote
newTab actions while leaving the local TUI's capability intact. 15 tests, 5 of
them end-to-end against a live server.
Done (2026-08-09). All four resolved; two were worse or wider than described.
- CORS — the entry understated this.
cors_middleware.go:109-113was not a permissive default, it was unconditional origin reflection. Browsers rejectACAO: *together with credentials, so the wildcard default failed CLOSED; that branch converted an inert misconfiguration into a working any-origin-with- credentials policy — live on the daemon, wired ahead ofPinAuthMiddlewareon a router serving/auth/login. Combined with the XFF item below, any page a victim visited could drive PIN guesses at their daemon and read the token from the response. The reflect-on-wildcard branch is deleted, credentials default to false,Vary: Originis emitted whenever the origin is request-dependent, and the comma-list bug (emitting the whole joined string as one invalidACAO) is fixed. The test that pinned the insecure default was updated. - X-Forwarded-For. New
httpfx.TrustedProxiesprimitive, allowlist empty by default (trusts nobody), nil-safe so a config error degrades toRemoteAddronly. Forwarded headers are read solely when the immediate peer is trusted, and the chain is walked right-to-left to the first untrusted hop — the old code took the left-most element, which is entirely attacker-supplied. Wired through config → server → auth handler, andresolve_address_middleware.go, which had the identical bug class, now shares the same primitive instead of repeating it. - QUIC cert pinning — already fixed by c06b36ce/b4ffb796 before this pass.
Both sites hash
rawCerts[0]only and additionally cover resumed handshakes viaVerifyConnection. The line numbers in the original entry were stale. eser updatefail-open. Both branches inverted to fail closed, and the match tightened fromincludes()to an exact second-field comparison mirroringinstall.sh— a substring test would accept the line for<archive>.sigor<archive>.sha256. The file had also moved tocodebase/cli-system/handlers/update.ts. The entry missed a fourth site:etc/scripts/install.ps1fails open too (and itsWebExceptioncatch is version-dependent, missing PowerShell 7'sHttpResponseException); fixed in the same pass.
Original description follows.
What: Four independent fixes, each small:
- Gate the PIN lockout's
X-Forwarded-Forbehind a trusted-proxy allowlist.pkg/ajan/noskillsserverfx/auth.godoes PIN auth properly (bcrypt cost 12,crypto/rand,subtle.ConstantTimeCompare) and its per-IP lockout is real — but the key is an unvalidated header on a daemon bound to:4433on all interfaces. The bypass is the header, not the mechanism. CorsMiddlewaredefaults toallowOrigin:"*"andallowCredentials:true, reflecting any origin with noVary. The TS twin defaultscredentials:falseand setsVary— match it.- QUIC cert pinning matches any cert in the attacker-supplied chain while
InsecureSkipVerifyis on:transport_http3.go:129-140, duplicated atwebtransport/client.go:107-120. HashrawCerts[0]only. eser updateinstalls when checksum verification cannot run (cli/commands/handlers/update.ts:157-183) while all three sibling consumers of the sameSHA256SUMS.txtfail closed. Make it fail closed too.
Effort: S each
What: Promote sendStreamEvent to the only emitter; thread ctx through
ParseJsonlStream; fix StreamIterator.Next holding iter.mu across a blocked
receive; pass the real cancel context in the three CLI adapters.
Why: 27 raw eventCh <- sends across 6 adapters block forever on a full
64-slot buffer, while the ctx-aware sendStreamEvent sits in the same package,
is used by the other 2 adapters, and is explicitly unit-tested.
StreamIterator.Next holds iter.mu across the blocked receive and Close
takes the same mutex — so the advertised remedy deadlocks. Three CLI adapters
take the cancel context into a parameter named _ and spawn on the parent
ctx, so Close() kills nothing.
Effort: M
Done (2026-08-01): All 27 raw eventCh <- sends across 6 adapters routed
through the ctx-aware sendStreamEvent (moved to stream_send.go); ctx
threaded through ParseJsonlStream; the three CLI adapters no longer discard
their context into _; StreamIterator.Next no longer holds iter.mu across
its blocking receive.
What: Declare the Node backend's returns as void*, use koffi.decode, and
free.
Why: pkg/@eserstack/ajan/ffi/backend-node.ts:355-358 has no binding to
EserAjanFree at all — 96 malloc'd C strings are never released. Deno and Bun
backends both do this correctly.
Effort: S
Done (2026-08-01): All 96 string returns re-declared void*, decoded with
koffi.decode(ptr, "char", -1), then freed. Measured end-to-end against the
real bridge: 73.7 MB -> 22 MB RSS growth over 200k calls.
Done (2026-08-09). loadEserAjan now shares handles, and
@eserstack/ajan/ffi/client is the single loader every package goes through.
All 18 real singletons are gone — a repo-wide grep for _libPromise outside
the ajan package returns nothing. The 14 ffi-client.ts files became ~11-line
re-export shims rather than being deleted, because those module paths are what
each package's own code imports; the 4 adapters/ffi/loader.ts copies now
import the shared client, so crypto, formats and parsing hold one handle each
instead of two. The 3 shell-local requireLib helpers were deliberately kept:
they are not loaders, just null-checks carrying a domain-specific message
("EserAjanShellPty* requires FFI or command-mode WASM"), and they now delegate
through the shim.
Sharing makes close() a cross-package action, so handles are reference
counted: each caller gets a wrapper whose close() is idempotent for that
caller, and the library really closes only when the last holder releases. That
prevents one package from unmapping a library another is still calling.
Deliberate second decision: a failed open is not cached, because the usual
causes (library not built yet, permission not granted) change within a process
lifetime and a later attempt should be allowed to succeed.
getLoadError() is the point of the exercise — every one of the old copies
ended in .catch(() => {}), discarding the checked-paths list and the
native-vs-WASM cause that ffi/mod.ts had carefully built.
Verified by ffi/client_test.ts: concurrent callers get distinct wrappers over
one shared symbols object; one holder closing leaves another's handle working;
a double-close does not decrement twice; the last close really closes and the
guard then refuses calls; a released entry reopens on the next request.
Not done: the callJson envelope helper this entry also proposed. Each
package throws its own typed errors with its own message mapping (see
formats/adapters/ffi/loader.ts), and adding a helper without migrating those
call sites onto it would just be dead code. Filed as remaining below.
Done (2026-08-10). callJson lives in @eserstack/ajan/ffi/client and the
four adapters/ffi/loader.ts files (formats, crypto, parsing, config) now go
through it — 8 call sites, each losing its own copy of ensure/null-check/
stringify/parse/error-test.
The design point that made earlier attempts not fit: the error TYPES are the one
genuinely per-package part. Each package throws its own class with its own code
mapping (FormatFfiError recovering FORMAT_NOT_FOUND from the message,
CryptoError recovering CRYPTO_UNKNOWN_ALGORITHM, and so on). So callJson
takes onUnavailable and onError factories rather than flattening everything
into one generic error — which is what I had previously judged would make the
helper dead code, and was the right call until it was shaped this way.
Two details worth keeping: invoke is awaited, so the nine symbols Deno marks
nonblocking work unchanged alongside the synchronous ones; and the error field
is only treated as a failure when it is a non-empty string, because the
bridge omits it on success and some responses legitimately carry error: null.
Not migrated, deliberately: the remaining call sites in logging, shell,
codebase and the AI bridge do not share this envelope — they stream, poll
handles, or shape their own responses — so routing them through callJson would
mean contorting the helper rather than removing duplication.
What: A single @eserstack/ajan/ffi/client exporting ensureLib / getLib
/ requireLib / getLoadError(), deleting the 19 duplicates. Generate the
99-symbol table from one manifest.
Why: 14 near-byte-identical ffi-client.ts files (cache, codebase,
collector, crypto, cs, formats, httpclient, kit, logging,
noskills, parsing, posts, shell, workflows) plus 4-5 more inlined
loaders in */adapters/ffi/loader.ts — 19 independent singletons of the same
24-line primitive. Three have already diverged: logging uses a top-level
await ensureLib() with a sync requireLib, codebase an async
requireLib with a different message, kit a fire-and-forget
void ensureLib(). Every one ends .catch(() => {}), discarding the genuinely
useful error string that pkg/@eserstack/ajan/ffi/mod.ts:199-260 constructs.
loadEserAjan has no memoization, so each loader performs its own
Deno.dlopen.
Context: This is the load-bearing seam joining the Go and TS halves and it
has no owner. Highest leverage-per-hour item in the audit after go.work.
Update (2026-08-09 FFI review): the count is now 21 copies, not 19 — 14
ffi-client.ts files (byte-identical modulo comments; verified by diffing
cache vs collector), 4 adapters/ffi/loader.ts re-implementations (config,
formats, crypto, parsing), and 3 shell-local requireLib wrappers
(shell/exec/child-go.ts:23-31, shell/exec/pty-go.ts:20-28,
shell/tui/keypress-go.ts:21-28). Worse: formats, crypto, and parsing each
carry two independent singletons in the same package, both reachable from
the package's public mod.ts (e.g. formats/mod.ts:58, crypto/mod.ts:18),
with divergent error mapping for the same symbols — so one package can hold two
dlopen handles. A process touching logging + shell + formats + codebase dlopens
the same dylib 4+ times. The fix shape stands: memoize inside ajan/ffi/mod.ts
(per-path) and ship @eserstack/ajan/ffi/client with
ensureLib/getLib/requireLib plus a callJson(symbol, req) envelope helper
(~10 lines of stringify → call → parse → error-check repeat at 30+ call sites,
~300 lines total).
Effort: M — done, see the summary at the top of this entry.
Mostly already fixed; the observability gap is now closed too (2026-08-09).
The "lossless" half was stale. The bridge already carries every generation
option -- Temperature, TopP, ThinkingBudget, ResponseFormat and Tools
all map through bridge.go, with the first three as pointers specifically so
"unset" stays unset rather than becoming a zero the provider would honour. The
//nolint:exhaustruct this entry blamed is gone from that path, and
bridge_ai_wire_test.go pins the mapping.
The "silently falls back" half was stale in its stated form. The choice is per-provider now, not all-or-nothing, and the TS adapters are retained deliberately: they are the only working path when no NATIVE binary loads, and the bridge refuses to load over WASM precisely so that case falls back rather than registering a provider that is present and broken. That reasoning is documented at the call site.
What was genuinely still open: the reason was discarded.
tryLoadBridgeFactories ended in a bare catch { return [] }, so falling back
silently changed which implementation answers every AI call with nothing left to
explain why -- no native binary for this platform, an ungranted FFI permission,
a library that failed to open. It now records the cause and exposes it as
getBridgeLoadError(), re-exported from adapters/mod.ts. The underlying
loader's own diagnostic (checked paths, native-vs-WASM cause) survives as the
error's cause, since ffi/mod.ts no longer discards it either.
Still open: nothing reports which implementation actually served a given
call. getBridgeLoadError() answers "why not the bridge", which is the question
a bug report needs; a per-provider "who served this" readout would need a
decision about where it surfaces (CLI flag, doctor output, structured log).
What: Stop silently falling back; surface which implementation ran; stop dropping options on the bridge path.
Why: pkg/@eserstack/ai/adapters/mod.ts:57-95 (defaultFactories()) tries
the Go bridge first and silently falls back to TS — so identical source
behaves differently depending on whether a .dylib happened to load on that
machine. The bridge path drops Tools, Temperature, TopP, ResponseFormat,
ThinkingBudget and all non-text content blocks
(pkg/@eserstack/ajan/bridge.go:454-486), under a //nolint:exhaustruct
suppressing exactly the linter that would have caught it.
Effort: M
Depends on: the memoized FFI client (shared load-error reporting)
The destructive half is fixed (2026-08-09); the TS side and the read-failure classification remain open.
WriteManifest no longer destroys the file. This entry's worst case was real
and reproduced before fixing: it marshalled NosManifest -- which models only
the noskills keys -- and wrote the result as the ENTIRE file, so an ordinary,
successful mutation deleted stack:, workflows: and scripts:. In this repo
that means deleting the workflows: block deno task cli ok executes. It was
reachable from the FFI bridge as well, not just the Go CLI.
It now merges the managed keys into the existing document through yaml.Node.
The obvious map[string]any merge would have passed a "foreign keys survive"
test while silently dropping every comment and reordering the document
alphabetically -- a smaller destruction, not the absence of one -- so there is a
second test pinning comments and document order. A manifest that exists but
cannot be parsed is now an error rather than something to overwrite.
Writes go through a new writeFileAtomic (temp file in the same directory,
Sync, Chmod, Rename), now used by WriteManifest, WriteState and
WriteSpecState. WriteState's doc comment had claimed "atomically writes
state" above a plain os.WriteFile since it was written.
Verified divergence found while doing this, and NOT fixed (it belongs to the
duplicated-domain entry, and reconciling it changes an on-disk format): the TS
writer nests the noskills keys under a noskills: key (state/persistence.ts
does doc.set("noskills", node)), while Go's ReadManifest/WriteManifest
read and write them at the document's top level. Neither implementation can
see what the other wrote. IsInitialized also only stats the file despite its
comment claiming it checks the noskills section; the comment is corrected in
place.
TS side done too (2026-08-10). writeTextFileAtomic (sibling temp file,
rename, temp removed on any failure) now backs the four state-bearing writers:
writeState, writeSpecState, writeConcern and the manifest writer. A
sibling temp is required, not incidental -- rename is only atomic within a
filesystem, so a system temp directory would silently degrade to a copy.
Absent vs corrupt is now split in readState and readSpecState. The Go
signature ((initial, err)) does not translate: every TS caller depends on
these reads not throwing, so changing that would ripple everywhere for no gain.
Instead the return value is unchanged and a corrupt file produces a one-time
per-path warning naming the file. That is the difference between "nothing to
restore" and "restore this" -- previously a truncated file was indistinguishable
from an uninitialised project.
A bug the test caught, worth recording. The first isNotFoundError sniffed
Deno's NotFound name and Node's ENOENT code. The cross-runtime fs port
normalises both into its own NotFoundError class, so neither matched and every
missing file was reported as corrupt -- precisely the false alarm the warning
exists to avoid. It now checks the class, keeping the raw forms as a fallback
for errors that bypass the port.
Finished (2026-08-10). Session bindings are atomic too -- they were filed
here as low stakes, which was wrong: they carry a copy of phase, and the
pre-tool-use enforcement gate reads it. The two .gitignore writes are the only
remaining plain writes, and they genuinely are low stakes (regenerated content,
no user work).
The list loops are fixed. The try used to wrap the entire walk in
listSpecStates and listConcerns, so one corrupt file aborted enumeration and
returned a silently truncated list -- every entry after the bad one looked
as though it did not exist, under a comment claiming the catch was only about a
missing directory. Now per-file, with the outer catch left for the absent
directory. The regression test names files so the broken one sorts first.
Worth noting where the correct pattern came from: listSessions, in the SAME
file, already had the per-file try/catch. This is the audit's recurring theme in
miniature -- the right primitive sitting next to the call sites that bypass it.
Temp files are safe against all three listings: they end in .tmp and every
walker filters on .json.
What: One writeJsonAtomic (temp + rename) routed through all four writers
in each language; split read failures into "absent" vs "corrupt"; make Go's
WriteManifest preserve unknown keys; move the try/catch inside the
listSpecStates / listConcerns loops.
Why: Every write is truncate-and-rewrite via plain writeTextFile with no
temp+rename, in both languages, under two source comments that falsely assert
atomicity — noskillsfx/persistence.go:136 says "atomically writes state" above
an os.WriteFile; noskills/state/persistence.ts:639 says "write both
atomically" above two sequential writes. Every read failure is caught by one of
175 bare catch {} and returns createInitialState(), so a torn file is
indistinguishable from a fresh project.
Worst case, and the reason this is the only theme that destroys user work: Go's
WriteManifest (persistence.go:219-234) marshals only NosManifest and
writes it as the entire file. Running the Go noskills concern add in this
repo would overwrite .eser/manifest.yml — including the workflows: block
deno task cli ok depends on — on a normal, successful run.
Context: The migration code in the same file already uses two-phase rename
with a comment explaining why, so the technique is known and simply absent from
the hot paths. Go already splits absent-vs-corrupt correctly at
persistence.go:115-134 — copy that.
Effort: M
The enforcement-gate half is fixed (2026-08-09); the single-writer half remains open.
The reader bug was real and is the part with teeth, because it silently relaxes
enforcement. handlePreToolUse in commands/invoke-hook.ts read
persistence.readState(root) directly in its no-session branch, so once a
spec-scoped command moved a spec's phase, the gate kept consulting the global
copy — still saying EXECUTING — and permitted file edits the state machine no
longer allowed. No crash and no error; enforcement just applied a phase that was
no longer current.
That branch now resolves through the per-spec file when the global store names a
spec, falling back to the global copy when the spec directory is gone.
resolveState already implements exactly that precedence, so this routes the
last reader through it rather than adding a second rule.
Scope note: the entry's other two reader paths were already correct — the
session branch uses the session phase, and the no-NOSKILLS_SESSION branch
picks the most restrictive phase across live sessions, which errs safe.
The regression test pins the invariant the gate depends on (a fresh per-spec
file beats a stale global one) in state/resolve-state-precedence.test.ts. It
does not drive handlePreToolUse end to end: that function is unexported and
reads env vars and stdin, so covering it directly needs a harness
disproportionate to a five-line change. Worth building if this area is touched
again.
Writer half done (2026-08-10) — and the "~39 call sites" framing was wrong in a way that mattered.
The real count is 9 non-test files that write both stores, not 39 call
sites. More importantly, only 4 of those write the SAME state to both and
were safe to migrate: block, reopen, and approve (twice). Those now call
writeStateAndSpec.
Routing "every call site" through the helper, as this entry advised, would have introduced bugs in five places:
done,wontfix,canceland the dashboard's complete action deliberately write DIFFERENT states — a terminal phase (COMPLETED / WONTFIX) is kept in the per-spec file for history while the global store returns to IDLE. One state to both would either erase the record or strand the global store in a terminal phase.resetcaptures the spec name BEFOREresetToIdleclears it. The helper keys offstate.spec, which is null by then, so it would have silently skipped the per-spec write and left that file stale — the exact class of bug the entry above this one is about.
All five now carry that reasoning at writeStateAndSpec's definition, so the
next reader does not "finish the job" and reintroduce it.
Still open: the third copy of phase in .eser/.state/sessions/*.json. Also
worth recording: the two writes inside writeStateAndSpec are sequential, not
transactional — each file is individually atomic now, so neither can be torn,
but a crash between them leaves them disagreeing. resolveState prefers the
per-spec copy, which is the conservative side of that race.
What: Route all writes through writeStateAndSpec; make every reader use
resolveState.
Why: State is dual-written to state.json and specs/<name>.json from ~39
hand-rolled call sites, while the correct combined helper
(state/persistence.ts:643 writeStateAndSpec) is used 3 times. A third copy of
phase lives in .eser/.state/sessions/*.json. Readers are split: spec-scoped
commands use resolveState (per-spec wins), but commands/invoke-hook.ts:256 —
the file-edit enforcement gate — reads the global store, so after
spec revisit it still sees EXECUTING and permits edits the state machine
disallows. No crash required.
Context: Call sites at
state/persistence.ts:105-119,248,283,311,347-371, 396-417,452-472.
Effort: M
The security-relevant half is done (2026-08-09); the inert knobs remain.
The unbounded pre-auth body is closed. ParseJSONBody now wraps the body in
http.MaxBytesReader. Deliberately a floor, not a policy:
RequestSizeLimitMiddleware is still wired into zero servers, so putting the
bound in the parser is what makes it hold for every caller regardless of which
middlewares a given server installs. This mattered because /auth/login parses
its request the same way, so an unauthenticated caller could stream until the
process ran out of memory. The limit cannot be set to "unlimited": zero or
negative restores the documented 50 MB default, since an accidental zero should
fail to the default rather than back to the unbounded read this exists to
prevent.
MaxRequestSizeMB and ExposeInternalErrors are now actually read, via
applyConfigPolicies called from both NewHTTPService and NewHTTP3Service.
Those two were the ones the project's own
.claude/skills/security-practices/references/rules.md instructs operators to
set, so this is what makes that guidance true rather than decorative.
ErrorHandlerMiddleware now does something. It was
result := ctx.Next(); return result -- a pure passthrough -- while installed
as the OUTERMOST middleware in the daemon under the claim that it wraps
everything. It now recovers a handler panic into a sanitized 500 instead of
letting it escape. net/http would recover it at the connection level, but by
killing the connection: the client sees a dropped request rather than a
response, and no Result-based shaping applies. http.ErrAbortHandler is
re-panicked, per net/http's contract.
Correction to this entry: it lists AuthMiddleware among the middlewares
with "zero non-test call sites". That is wrong -- it has two. The genuinely
unwired ones are RequestSizeLimitMiddleware, RateLimitMiddleware,
SecurityHeadersMiddleware, MetricsMiddleware and TracingMiddleware.
Still open: APIKeys, SkipAuthPaths, InitializationTimeout,
RateLimitRequests and AuthEnabled are still declared and read by nothing;
JWTSecret still carries a "validated at startup" comment with no startup
validation; the five middlewares above are still wired nowhere; and the proposed
httpfx.DefaultMiddlewares(cfg) helper does not exist. Each remaining knob
needs the delete-or-implement decision this entry's title asks for -- that is a
product call about which of them are meant to exist at all, not something to
guess.
What: Add httpfx.DefaultMiddlewares(cfg *Config) []Handler translating
config into wired middlewares; call
SetDiscloseErrors(cfg.ExposeInternalErrors) from the service constructors; add
MaxBytesReader inside ParseJSONBody as a floor. Delete every knob with no
home rather than implementing it.
Why: pkg/ajan/httpfx/config.go declares APIKeys (:8), SkipAuthPaths
(:13), InitializationTimeout (:20), RateLimitRequests (:24),
MaxRequestSizeMB (:25), AuthEnabled (:34), ExposeInternalErrors (:36) — a
repo-wide grep returns only the declarations. pkg/ajan/config.go:20 adds
JWTSecret with the comment "validated at startup"; there is no startup
validation. RequestSizeLimitMiddleware, RateLimitMiddleware,
SecurityHeadersMiddleware, AuthMiddleware, MetricsMiddleware,
TracingMiddleware and lib.ValidateExternalURL all have zero non-test call
sites. ErrorHandlerMiddleware
(httpfx/middlewares/error_handler_middleware.go:5-11) is literally
result := ctx.Next(); return result — wired as the outermost middleware under
the comment "error handler wraps everything".
Context: The project's own
.claude/skills/security-practices/references/rules.md:89-105 instructs
operators to set RATE_LIMIT_REQUESTS, MAX_REQUEST_SIZE_MB and
EXPOSE_INTERNAL_ERRORS — all three inert. httpfx/context.go:86-101 parses an
unbounded JSON body, reachable pre-auth on /auth/login.
Effort: M
Done (2026-08-09), as processfx.HardenCommand rather than a Spawn
wrapper.
The shape matters: a Spawn(ctx, argv, opts) that owns spawning would have had
to absorb every call site's own stdio, capture and env handling. HardenCommand
takes the *exec.Cmd the site already built and fixes only the part that was
wrong -- process group, group-kill on cancel, WaitDelay -- so migrating a site
is one line and cannot change its I/O behaviour.
Applied to the cancellable sites that lacked it: processfx.Run (covering both
Exec and Run), codebasefx/git.go, noskillsserverfx/worker.go,
noskillsserverfx/projects.go (a git clone, which can hang indefinitely), and
pkg/@eserstack/ajan/bridge.go's sh -c -- the exact grandchild case. The two
sites that were already correct (shellfx/exec, workflowfx/tools_unix) were
left alone; the non-cancellable exec.Command sites take no context, so
cancellation cannot orphan anything there.
A bug in the first draft, worth recording. HardenCommand originally
guarded the assignment with if cmd.Cancel == nil. But exec.CommandContext
has already installed a Cancel that kills the direct child only -- that
default IS the defect -- so the guard made the function a silent no-op for
exactly the commands it existed to fix. The grandchild test caught it: 10s
timeout before, 0.01s after. processfx.Exec cancellation likewise went from
5.25s (the WaitDelay fallback expiring) to 0.25s (a real group kill). Cancel
is now replaced unconditionally, while WaitDelay remains a default a caller
can override -- the test pins that distinction.
Windows is honest about its limits: no process groups are set there, because killing a tree needs a Job Object. The direct child is still killed, which is what happened before; the code says so rather than implying more.
What: Export processfx.Spawn(ctx, argv, opts) implementing process-group
kill plus cmd.WaitDelay, and route every exec.Command* site through it.
Why: setupCancelKill (pkg/ajan/workflowfx/tools_unix.go:19) does this
correctly and covers 1 of 16 exec.Command* sites.
pkg/@eserstack/ajan/bridge.go:2224-2255 is one of the sites that does not.
Effort: M
Done (2026-08-09) — and the connfx half of this entry was wrong.
Router.Group() now panics with an explanation instead of returning an
unmounted router whose every route 404s. It had zero non-test callers. Note it
was broken twice over: the fresh http.ServeMux was never mounted into the
parent, AND Route ignores r.path entirely (see its own TODO), so a prefix
would not have been applied even after mounting. Both halves have to land
together, so implementing it is not the small change this entry implied.
Panicking matches how the file already reports programmer error (RouteRaw
panics on a frozen router). Deletion should ride the 5.0.0 major.
The existing TestRouter_Group is worth calling out: it asserted only that
GetPath() concatenated the prefixes, never registering a route or issuing a
request — so it passed for as long as the feature was completely non-functional.
It now pins the refusal.
Do NOT delete the connfx port layer. This entry called it "366 lines of
Repository/Queue ports satisfiable by no adapter". That is false, and acting on
it would have deleted working code: RedisAdapter implements Repository in
full, pinned now by repository_reachability_test.go. Most of data_ports.go
is also load-bearing inside the package — Message has 57 internal uses,
ConnectionCapability 18.
The real defect was reachability, not absence. GetRepository asserted
against conn.GetRawConnection(), which every adapter implements by returning
the vendor handle (*redis.Client), while the Repository methods live on the
adapter — so it could never find them and returned ErrInterfaceNotImplemented
100% of the time, exactly as the audit measured. It now checks the connection
itself as well as the raw handle and reports what it tried.
Still open (small, no consumer): wiring RedisAdapter through so
GetRepository can actually return it needs an adapter accessor on the
Connection port. That is a design change, and nothing calls GetRepository
today, so it was not invented speculatively.
What: Either mount the group's mux into the parent router, or delete
Group(). Delete the connfx Repository/Queue port layer rather than repairing
it.
Why: pkg/ajan/httpfx/router.go:91-95,200-203 — Group() returns a fresh
unmounted mux, so grouped routes 404 with no error at registration time.
pkg/ajan/connfx/data_ports.go:42-226 is 366 lines of Repository/Queue ports
satisfiable by no adapter, and registry.go:315-342 (Registry.GetRepository)
type-asserts the vendor object (*redis.Client, *sql.DB), so it returns
ErrInterfaceNotImplemented 100% of the time. GetRepository has no callers —
deletion is the correct fix.
Effort: S
Done (2026-08-09). All three defects in this entry were still live and are fixed.
reflectSetField now returns error and every one of its ~16 conversions
propagates instead of discarding into _. All three call sites carry the key
path outward, including the slice-element one this entry did not mention. This
was the damaging one: an invalid override did not merely get ignored, it beat
the declared default and assigned zero while reporting success — and zero is
not inert, since net/http reads a zero Read/WriteTimeout as no timeout. A
typo in a deploy environment silently removed the timeouts it was meant to set.
Pinned by configfx_invalid_value_test.go across int, bool and duration.
The two existing float tests that this entry predicted "will need updating" did exactly that — both literally documented the fail-open behaviour as intended ("malformed float32 should silently produce zero"). They now assert rejection.
The ${VAR} panic is fixed. expandVariables used a bare map index plus
.(string), which panicked on any undefined name, and panicked even for
defined names under case-insensitive parsing, because keys are stored through
lib.CaseInsensitiveSet but were read with the literal spelling. Reproduced
before fixing, in envparser_expand_test.go. This one mattered beyond configfx:
the same code runs inside the cgo bridge, where a panic unwinds past the FFI
boundary and aborts the host process. Undefined now expands to empty, following
POSIX and godotenv, which this parser is a port of.
The named-return/defer bug is fixed in both parsers:
defer func() { err = file.Close() }() overwrote the parse error
unconditionally, so a truncated or corrupt file was indistinguishable from a
valid one. The close error is now recorded only when the parse itself succeeded.
Pinned by configfx_corrupt_file_test.go.
What: Make reflectSetField return error and propagate with the key path;
fix the named-return + defer bug in both file parsers; use comma-ok plus
lib.CaseInsensitiveGet in expandVariables.
Why: pkg/ajan/configfx/manager.go:426-511 (reflectSetField) discards the
error from all 16 conversions and cannot propagate one — the signature returns
nothing. Both file parsers overwrite the parse error with Close()'s nil via a
named-return + defer bug (jsonparser.go:41-56, envparser.go:341-360).
Reproduced: PORT=not-a-number DBG=yes READ_TO=30 yields
err=<nil> port=0 dbg=false rt=0s — the invalid override beats the declared
default and lands on zero. Go's net/http treats a zero read/write timeout as
unlimited, and duration fields with defaults exist across httpfx, httpclient,
connfx, aifx and workerfx. A truncated config.json is indistinguishable from a
missing one.
Context: Separately, an undefined ${VAR} in a .env panics through an
unchecked type assertion (envparser.go:323) on the default load path —
including inside the cgo bridge, taking the host process down. Case-insensitive
mode makes it panic even when the variable is defined. Two existing tests pin
the broken float behaviour and will need updating.
Effort: M
Done (2026-08-09) — the argv half was already fixed; the rest is now closed.
The argv-corruption claim is stale. Every corruption this entry lists was
already repaired by earlier work, and parser.ts carries the comments recording
it. Verified rather than assumed: all four named cases round-trip exactly
(a\b, C:\Users\x, ${""} keeping its argv slot, and the a\' --evil x
injection arriving as one token), and then a 200,000-case fuzz over an
alphabet of quotes, backslashes, $, backticks, pipes, semicolons, ampersands,
tabs and newlines found zero violations of the real invariant: one
interpolated value must arrive as exactly one byte-identical argv entry. That
invariant is now a test (command.test.ts) so it cannot silently regress.
pipe() was genuinely broken and now refuses. PipedCommandBuilder.text()
ran each command in turn and overwrote its input variable with each one's
stdout, never writing anything to the next child's stdin — so a.pipe(b).text()
returned b's output with b reading the inherited stdin. It reported success
while answering a question nobody asked. pipe() now throws with a message
pointing at .child(), the unreachable PipedCommandBuilder is deleted, and
the mod.ts doc example that advertised the broken feature is gone. Deletion of
the method should ride the 5.0.0 major.
Tests exist now. This entry said there were none under
pkg/@eserstack/shell/exec/; parser.test.ts had since appeared, and
command.test.ts adds the round-trip invariant, the empty-interpolation slot,
and the pipe() refusal.
What: Stop serialising to a string — push each interpolated value directly
into the argv array. Delete pipe() or make it throw. Add a test file; there is
none under pkg/@eserstack/shell/exec/.
Why: codebasefx/validators.go:545 rewrites new Deno.Command violations
to "@eserstack/shell/exec", making this the blessed path (~50 call sites). Its
template tag shell-quotes each interpolated value (parser.ts:47-54) then
re-tokenizes with a parser that treats \ as an escape inside single quotes
(:60-112) — quote and unquote are not inverses. Verified by execution:
${"a\\b"} becomes ab, ${"C:\\Users\\x"} becomes C:Usersx, ${""}
silently vanishes and shifts positional args, and ${"a\\' --evil x"} escapes
its own quoting to inject a separate --evil argv entry.
cli/commands/handlers/update.ts:194-197 interpolates Windows paths into
PowerShell, so CLI self-update is simply broken on Windows. pipe()
(command.ts:326-337) reassigns input per stage and never feeds any child's
stdin.
Effort: M
What: For each duplicated concept, declare a single source of truth. Move
the tables (VALID_TRANSITIONS, QUESTIONS, RESERVED_NAMES, capability rows,
the state schema) into data loaded by both languages, and add a testdata/
golden corpus executed by both deno test and go test inside
deno task cli ok.
Why: pkg/ajan/noskillsfx/ (4,584 lines) re-implements
pkg/@eserstack/noskills/ (46,115 lines) over the same on-disk files —
.eser/.state/progresses/state.json, specs/<name>.json,
ledger/<spec>/ledger.jsonl — with 23 explicit // mirrors X.ts comments
across 10 files. All 8 AI providers exist twice (pkg/ajan/aifx/adapter_*.go
≈12.7k LOC vs pkg/@eserstack/ai/adapters/*.ts ≈4k LOC) plus a third FFI path.
The 99-symbol C ABI is restated by hand in 7-8 files. There is no JSON Schema,
no codegen, no golden corpus; a repo-wide grep for parity or conformance
harnesses returns nothing.
Drift is verified, not hypothetical:
- A legacy
discovery.userContextstring hard-failsjson.Unmarshalin Go while the TS CLI reads it fine (reproduced). noskillsserverfx/specs.go:381callsWriteSpecState, which in Go never appends a ledger record — whilespecs.go:134serves a/ledgerendpoint reading the file only the TS CLI writes.- Go's
InferClassificationfeeds the spec slug into keyword matching; TS's does not. RESERVED_NAMESis 21 entries in TS, 20 in Go.- In aifx each copy is broken where the other is correct: Go nests the entire
JSON Schema one level deep in
input_schema.propertiesand never setsrequired(adapter_anthropic.go:636-661, reproduced on the wire), while TS sendstool_choice: "required", which Anthropic rejects (adapters/anthropic.ts:311). - Anthropic streaming double-counts output tokens (
adapter_anthropic.go:293plus:359, 2× measured) and emits two contradictoryMessageDoneevents. - Capability metadata now exists in three tables and 4 of 8 providers disagree
(third table at
pkg/@eserstack/ai/adapters/ajan-bridge.ts:523-540).
Failure mode: every domain change is a two-language edit that nothing verifies,
and the answer a user gets depends on which binary ran, which depends on whether
a .dylib happened to load.
Context — do this measurement first. Before committing to keeping both sides, benchmark whether the FFI hop is actually faster. The entire justification is a source comment saying "provides better performance", and every call is a JSON marshal plus a C-string round-trip. If it is not faster at most call sites, the cheapest resolution of the most expensive theme is deletion, not synchronisation.
Effort: XL — but strictly cheaper now than after more surface accretes. All three judge panels ranked this #1 or #2.
Other citations:
pkg/ajan/noskillsfx/{machine.go:10, compiler.go:12,405, persistence.go:159,275, slug.go:32-37, schema.go:219},
pkg/@eserstack/noskills/{context/compiler.ts:496, state/persistence.ts:127-160,311}
What: One traversal producing the chunk stream, with SSR materialising React
elements from those same chunks. Minimum viable today: a shared
serializeRef(id) helper plus a round-trip test asserting both emitters produce
identical chunk arrays.
Why: preprocessTree
(laroux-server/adapters/react/ssr-renderer.ts:173-619) and renderElement
(.../rsc-flight-renderer.ts:78-320) are two independent 400-600 line
traversals of the same tree emitting the same chunk protocol, consumed by one
parser. They have diverged: ssr-renderer emits `$${chunkId}` for arrays;
rsc-flight-renderer emits raw integers (:101-102) — and parseModel
(laroux-react/client.ts:501-575) only dereferences strings starting with $,
so chunk IDs render as literal text. The same file is internally inconsistent
(renderProps at :344 correctly emits $N).
Separately, createClientPlaceholder (ssr-renderer.ts:132-166) substitutes
a <div data-client-component style="display:contents"> for every client
component in the server HTML, while the RSC chunk for that node has no wrapper.
In the default streaming-optimal mode the client hydrates the whole root from
the payload — a guaranteed structural mismatch on every page containing a link
(link.tsx is itself "use client"), swallowed by onRecoverableError at
debug level under the comment "Hydration mismatches are expected for async".
Context: docs/adr/0003 already named the fix ("Option 2 … single source of
truth for tree traversal") and its Related Files point at a package that no
longer exists — update the ADR. Do this only after adding one fixture app; there
is currently no consumer exercising laroux at all.
Effort: XL structural / S for the shared helper plus round-trip test
What: Set a patch-coverage floor; measure Go coverage; add golden-output tests for the bundlers and a fixture app that actually starts a server.
Why: Coverage is informational: true with continue-on-error: true, and
Go coverage is never measured at all. The bundler backends are tested only as
constructors — rolldown.test.ts is 342 lines with zero references to bundle
/ build / transform. No test in the TS tree ever starts a server or calls
.bundle().
Effort: S for the floors, XL for bundler/SSR golden tests
Depends on: the go.work and CI-native-lib items above
What: Investigate and, if confirmed, replace with an injected runtime port.
Why: It is a top-level-awaited ambient I/O singleton imported by 222 files, which the project's own bundler string-replaces with a hand-written shim. Two subsystem auditors independently named it as the mechanism by which "business logic stays dependency-free" gets violated repo-wide — but the specific finding was refuted on its stated impact, so no verified finding covers it. This is a known unknown, not a confirmed defect: scope it before committing to a fix.
Context: pkg/@eserstack/standards/cross-runtime/mod.ts:240-241 carries an
explicit // deno-lint-ignore no-top-level-await.
Effort: M to investigate
What: Either connect the exporters or document them as unwired.
Why: The observability seam exists but nothing connects it, so none of the hangs, leaks or freezes listed above are observable when they fire. That is the real cost, and it is why this sits at P2 rather than P3 despite the original "every span is a silent no-op" framing being refuted as overstated.
Effort: M
What: Split the 4,954-line bridge.go into per-subsystem files inside
package main; either drain handles in bridgeShutdown or document
Init/Shutdown as no-ops; refresh the stale require line in
pkg/@eserstack/ajan/go.mod.
Why: bridgeShutdown (bridge.go:262-267) sets initialized = false and
reclaims nothing, and initialized is write-only — grep returns the declaration
at :54 and two writes, and no reads. So Init/Shutdown imply a lifecycle
contract they do not honour. Note the handle maps themselves are not
leaking: all 13 have explicit release paths (16 delete() sites, each closing
the underlying resource), and TS callers do release them with a double-close
guard (shell/exec/child-go.ts:98-108).
Context: The god-file shape is partly forced — cgo //export requires every
exported symbol to live in one package main, so it cannot be split across Go
packages. It can be split across files within package main; the tests
already use that convention (bridge_http_stream_test.go, bridge_log_test.go,
bridge_codebase_stream_test.go).
Update (2026-08-09 FFI review): bridge.go is now 5,510 lines. Concrete
duplication inventory for the split: ~10 registry quadruplets (map + RWMutex +
create/lookup/close) that a generic handleRegistry[T] would collapse; 5
near-identical stream create/read/close triplets (codebaseWalkStreamState and
codebaseValidateStreamState at bridge.go:4476-4486 are byte-identical
structs); aiHandleResponse re-declared four times (cacheHandleResponse,
postsHandleResponse, parsingTokenizerHandleResponse,
tuiKeypressCreateResponse); the kit chainResult→results loop appears 4 times
(bridge.go:3458, 3568, 3646, 3711); URL/header assembly duplicated between
bridgeHttpRequest (1417-1446) and bridgeHttpRequestStream (1555-1581).
Estimated 1,500+ lines removable. Also: noskillsBridgeDetectBranch (2671-2685)
hand-parses .git/HEAD while codebasefx.GetCurrentBranch is imported and used
in the same file (4070). On the Init/Shutdown honesty question: only the AI
adapter ever calls Init (ai/adapters/ajan-bridge.ts:903), nothing on the TS
side ever calls Shutdown or lib.close(), and because all consumers share one
Go image with global registries, any single consumer calling EserAjanShutdown
would destroy every other package's live handles via closeAllHandles — the
current safety is "nobody calls it", not isolation.
Effort: S (split) / M (with the generic registry + stream state)
What: For each item below, prefer deletion over a fix. All three judge panels independently listed most of these as overrated on impact; the honest question is whether they should exist at all.
streamfx— zero importers, four verified defects.connfxAMQP adapter — its "reconnection logic" comment sits above a body that has none, and itsCloseleaks the connection when the channel is already dead.processfx.Supervisor— no production callers.RateLimitMiddlewaregoroutine leak — never wired, so unreachable today.functions/pipeline.ts collect()— mints a freshstate: {} as Sper middleware, so the documented Koa-style shared context never shares (verified:state seen: {}); the double-next()guard compares against a single sharedprevIndex, so a 3-stage chain runs downstream twice with no error.di/container.ts— memoizes async lazy singletons only after resolution, so concurrentget()double-constructs (verified:built times: 2).fp/deep-copy.ts:89-92— doesnew Type()and copies onlyObject.keys, so a copiedDatebecomes now, aMapbecomes empty, andObject.create(null)throws.
Effort: S each, or zero if deleted
Done (2026-08-10). Result gained InnerContentType plus a ContentType()
accessor; JSON and PlainText set theirs, and the router writes the header.
Two ordering details that make or break it: the header must be set BEFORE
WriteHeader or net/http discards it, and it is only set when the existing
header is empty, so a middleware or raw handler that already chose one wins.
An empty content type keeps the old sniffing behaviour, so the ~120 Result
literals that do not know their type are unaffected — which is also why this did
not need touching all 85 call sites. (exhaustruct is referenced by //nolint
comments throughout this package but is not actually enabled in
.golangci.yaml, so adding a field broke no keyed literal.)
This fix exposed a data race I had introduced earlier. Wiring
ExposeInternalErrors into the service constructors meant SetDiscloseErrors
now runs at construction, while discloseErrors was a plain bool read by
every request goroutine — a genuine race that go test -race caught across a
dozen httpfx tests. It is now atomic.Bool, matching the treatment
configuredMaxRequestBodyBytes already had. The setter had existed for a long
time; nothing called it outside tests, so the unsafety was latent until the knob
was actually connected.
What: Give Result a header field, or special-case the content type.
Why: No Result carries a header, so Results.JSON — 85 call sites,
including /openapi.json — is served as text/plain.
Effort: S
What: Add size- or age-based rotation to the append-only .jsonl stores.
Why: Unbounded growth on long-lived projects. Flagged in the audit's closing notes rather than as a verified finding — the original "O(n^2) hot path" framing was refuted, but the unbounded growth is real.
Effort: S
What: Add a Windows job that actually runs the CLI and the PTY paths, not just compiles them.
Why: CI compiles and publishes x86_64-pc-windows-msvc, but no test
exercises it. The shell/exec argv-corruption item above independently
establishes that CLI self-update is broken on Windows today
(cli/commands/handlers/update.ts:194-197) — which is exactly the class of
defect an e2e job would have caught.
Effort: M
Depends on: the @eserstack/shell/exec argv fix
Findings from a focused review of the eser-ajan FFI seam: Go bridge
(pkg/@eserstack/ajan), the three TS backends, the WASM fallback, and all
consumer packages. Four parallel review agents (symbol parity, TS backends, Go
bridge, consumer wiring) plus live verification; the two highest-severity Go
findings were re-verified by hand against the source.
Verified working — baseline, not TODOs. All 100 //export symbols in
main.go match the built dylib (nm diff), ffi/types.ts, and all three
backend declaration maps 1:1 (names, arity, pointer/int types). Live smoke test
passes on Deno, Bun, and Node against the native dylib: init/version, JSON
round-trips, handle lifecycle, and malformed JSON returns structured errors
without crashing. go test ./... passes; formats/crypto/parsing/ai FFI suites
all pass. Wiring contracts (TS request/response shapes vs Go structs) verified
field-for-field for logging, formats, codebase, and ai. No consumer bypasses the
unified loader. String freeing in the wrapper layer is centralized and leak-free
on happy paths. (Local-only observation, no action:
dist/aarch64-darwin/libeser_ajan.dylib was built at 4.3.0, one release behind
VERSION; CI rebuilds fresh so this is a stale local artifact.)
Two findings from this review were folded into existing audit entries rather
than filed here: the consumer-loader duplication update (now 21 copies, dual
singletons per package) lives in P1 — One memoized FFI client, and the
bridge.go duplication inventory + Init/Shutdown footgun lives in P2 — Split
bridge.go.
Status (2026-08-09, same day): everything filed in this section is done except two items, both explicitly noted below: the full symbol-table collapse (its highest-value half, the machine parity check, shipped) and making Bun/Node AI calls genuinely async (documented instead). Also fixed in the same pass, from the earlier audit: P0 — Related lower-severity security hardening, and P1 — One memoized FFI client (18 duplicated loader singletons collapsed onto one shared, reference-counted handle).
deno task cli okis green end to end. Independently re-verified rather than taken from agent reports:gofmt/vet/go build/go test -raceon both Go modules; theGOOS=wasip1build; exactly 100 cgo exports before and after the wholesalemain.gorewrite; the native smoke test on Deno, Bun and Node; the WASM fallback now raising a named, actionable error; and the full Deno suite at 2248 passed / 0 failed.Two things the fixing pass surfaced that the review had not:
CloseIdleConnectionswould have been a silent no-op written the obvious way —ResilientTransportdoes not implement it, so the promotedhttp.Clientmethod does nothing. It has to reach the inner transport.- The
dist/wasi/artifact simply had never been built locally, which is the whole reason the fallback resolved the published@eserstack/ajan-wasm@4.1.57instead. Oncescripts/build.tsruns, the local 4.3.1 wasm wins. That sub-finding was a stale local artifact, not a code defect — no fix was needed or made.Unrelated pre-existing gate failures fixed in passing:
.agents/(the vendor-neutral mirror of.claude/, untracked) trippedvalidate-filenamesbecauseSKILL.mdis a fixed convention name that cannot be kebab-case, andvalidate-secretson a security doc that teaches by counter-example. Both got the exemption.claude/already had, in.eser/manifest.yml.One structural consequence worth knowing: extending the security fix pushed
pkg/ajan/noskillsserverfx/server.gopast the deliberate 500-linevalidate-server-loclimit, so its session REST handlers moved tosession_handlers.go, matching the existingauth.go/auth_handlers.gopairing. server.go is now 423 lines.
Done (2026-08-09). Culled, not faked: handle-based symbols now raise a named
error stating that the module is re-instantiated per call, that handles cannot
survive, and how to fix it (install the platform package or set
ESER_AJAN_LIB_PATH). Stateless symbols still work — verified live,
FormatList returns data while LogCreate throws. ShellPtyRead rejects
rather than throwing synchronously, so its Promise<string> contract holds.
Reactor "" stubs now return valid JSON, and the unusable reactor AI exports
carry a comment explaining why the host cannot call them. The stale-4.1.57
sub-finding needed no fix (see the status note above).
What: Either keep one live WebAssembly.Instance across calls in the
command-mode loader, or cull the command-mode symbol surface to stateless
functions (format, crypto, config, codebase one-shots) so handle-based calls
fail loudly instead of lying.
Why: wasm/loader-command.ts:41-69 instantiates a fresh WASI instance
(fresh linear memory) per symbol call, and main_wasi.go:24-35 serves exactly
one request per _start — so every Go-side registry (modelHandles,
logHandles, httpStreamHandles, ptyHandles, …) resets between calls.
Reproduced live: EserAjanLogCreate returns {"handle":"log-1"} and
EserAjanLogClose one call later returns
{"error":"log handle not found: log-1"}. Yet the loader exposes all ~100
symbols, and loadEserAjan() falls back to it automatically, so on any machine
without a native library every stateful API silently misbehaves (stream reads
return "null" immediately, model handles vanish, …).
Context: Command mode is the default (wasm/mod.ts:69). Related rot in
the same layer, fix or delete together:
- The repo's own WASM fallback resolves the published
@eserstack/ajan-wasm@4.1.57from node_modules — reproduced live:ESER_AJAN_NATIVE=disabledreportseser-ajan version 4.1.57against a 4.3.x native bridge. Two minors of silent behavioral drift on the fallback path. - Reactor loader stubs return
""(not valid JSON) fromLogClose,AiCloseModel,AiFreeStream,HttpClose,HttpStreamClose,CacheClose(wasm/loader-reactor.ts:134,149,150,167,173,199) — callers thatJSON.parseget "Unexpected end of JSON input" instead of a clear error. main_wasi_reactor.go:66-98exports 6 AI functions whose arg-passing protocol is unimplementable from the host (args are read out ofresultBuf, but no export lets the host write into it;eser_ajan_result_ptrreturns null while the buffer is empty, :31-33). The TS loader correctly stubs them all — the Go exports are dead weight. Treat reactor mode as experimental or remove it (matches the standing note in P2 — Pick one owner).
Effort: S (cull to stateless + fix stubs) / M (persistent instance)
Done (2026-08-09). panic_guard.go adds
guardString/guardInt/guardVoid (stack trace to stderr, compact JSON to the
caller). main.go was rewritten wholesale so all ~100 exports funnel through
them, with C.GoString argument marshalling deliberately inside the guarded
closure so malformed input is covered too. The WASI dispatch is guarded in one
place; the reactor's resultBuf slicing is bounds-checked. Export set verified
byte-identical at 100 before and after. Note the limit: recover() cannot catch
Go runtime fatal errors (concurrent map writes, the semaphore class) — only
panics.
What: One shared wrapper (defer recover() returning
{"error":"panic: …"}) that every //export in main.go routes through;
bounds-check the WASI reactor's buffer slicing.
Why: grep recover() over pkg/@eserstack/ajan returns zero hits
(verified). Malformed JSON is handled at every entry point, but a panic anywhere
in bridge.go or any downstream pkg/ajan/*fx package unwinds through cgo and
aborts the host Deno/Node/Bun process — the no-panic guarantee currently
rests on every transitive dependency instead of on the boundary. One concrete
in-repo panic path exists today: main_wasi_reactor.go:68-101 slices
resultBuf[:hostSuppliedLen] with no bounds check, and resultBuf is nil
before the first setResult.
Context: The 2026-08-01 audit already documented a related class (config
envparser panic "including inside the cgo bridge" — see P1 — Config parsing
fails open). A boundary recover() converts that whole class from process-abort
to a JSON error.
Effort: S
Done (2026-08-09). All three. The log entry gained a mutex plus a
snapshot() that reads filters and formatter together under one RLock;
the race test reproduces WARNING: DATA RACE when the locks are removed. The
formatter path now gates on the same Enabled predicate shouldLog answers
with. On the TS side, logger.ts parses both responses and degrades to a stderr
console sink, warning once per process — logging never throws and never silently
drops a line. Two deliberate choices: stderr not stdout (a log line on stdout
corrupts piped output, the bug the ffi debugLog comment records), and a
substring marker rather than a full JSON.parse on the hot path.
What: Three defects in one pipeline; fix together and add a -race test.
- Per-entry synchronization for log handles:
bridgeLogConfigure(bridge.go:1955-1975) looks the entry up underlogMu.RLock, releases, then writesentry.filters/entry.formatter/entry.levelVar, whilebridgeLogWrite(bridge.go:1866-1877) readsentry.filters/entry.formatterwith no lock. Deno'snonblocking: truesymbols run on a thread pool, so these are genuinely concurrent — a real Go data race (function-value word tearing). Verified by hand.logMuprotects only the map, not the entries; entries need their own mutex or atomics. - Custom formatter bypasses the level gate: with
entry.formatter != nil,bridgeLogWritewrites to stderr unconditionally (bridge.go:1870-1877);levelVaris only consulted on the slog path. AfterlogConfigure {formatter:"text", level:"ERROR"}, a DEBUG write still emits — whilebridgeLogShouldLogfor the same handle says false. - The TS client swallows both failure signals:
#ensureGoHandle(logging/logger.ts:94-108) parses{handle?, error?}but ignoreserror, and theEserAjanLogWritereturn value is never parsed (logger.ts:238-245). If create fails,#goHandlestays undefined, Go returns{"error":"log handle not found: "}(bridge.go:1847-1852), and every subsequent log line vanishes with no signal.
Context: Root cause is architectural: bridgeLogCreate/bridgeLogWrite
hand-assemble a slog handler and re-implement filter/format emission instead of
logfx owning it — the first two bugs are direct consequences. Consider routing
through logfx as part of the fix.
Effort: S (locks + gate + TS error check) / M (route through logfx)
Done (2026-08-09). Fixed at the root cause rather than by correcting the
list: name and function now travel together as validatorEntry pairs, so the
two lists cannot drift again. Unknown requested names now return
ErrUnknownValidator instead of being dropped. Both call sites share the
helper. TestDefaultValidatorEntriesMatchBuiltins compares function pointers
against codebasefx.BuiltinValidators(), so reordering there cannot silently
reintroduce this. The consumer sweep found nothing pinning the wrong names — all
17 withGoValidator call sites pass a single name and overwrite the label.
What: Zip results with names at resolution time instead of keeping two parallel lists; error (or at least report) on unknown requested validator names. Add one assertion on result names to the non-stream test path.
Why: Verified by hand. Default path: resolveValidators(nil, …) returns
codebasefx.BuiltinValidators() ordered
[EOF, TrailingWhitespace, BOM, MergeConflicts, LineEndings, Secrets]
(pkg/ajan/codebasefx/validators.go:619-628), but validatorNames returns
["eof","bom","trailing","line-endings","merge-conflicts","secrets"]
(bridge.go:4354-4360) — indices 1-4 are mislabelled: trailing-whitespace
issues are reported as "bom", BOM as "trailing", merge conflicts as
"line-endings", line endings as "merge-conflicts". Requested path:
resolveValidators silently drops unknown names (bridge.go:4344-4348) while
validatorNames echoes the requested list verbatim — ["bogus","eof"] yields
EOF results labelled "bogus". Same defect duplicated in
bridgeCodebaseValidateFilesStreamCreate (bridge.go:4642-4648).
Effort: S
Done (2026-08-09). Removed the hazard rather than racing it: ptr() is gone
from the backend entirely and buffers are passed as TypedArrays, which bun:ffi
keeps alive across the call. Merely holding a reference in the calling frame
would have worked too, but leaves the trap armed for the next wrapper someone
adds.
What: Keep the encoded Uint8Arrays alive across the FFI call (return the
buffer from toCString and hold it in the caller's frame, or pass TypedArrays
directly — bun:ffi accepts them for ptr args, which removes the hazard
entirely).
Why: toCString (ffi/backend-bun.ts:442-446) passes ptr(encoded) out
while encoded becomes unreachable the moment the function returns; bun:ffi's
ptr() returns a raw number and requires the TypedArray to outlive the pointer.
Worst case is the two-argument calls (EserAjanAiGenerateText /
EserAjanAiStreamText, backend-bun.ts:485-505): allocating the second buffer
can trigger a GC that collects the first before the call executes. The Deno
backend explicitly guards this exact hazard (backend-deno.ts:529-531 — "both
buffers must stay referenced… or the GC could free memory Go is reading"); the
Bun backend does not.
Effort: S
Partially done (2026-08-09) — item (c), the machine parity check, has shipped;
(a) and (b) remain open. The drift risk this entry exists to remove is now
guarded even though the duplication itself is not yet collapsed:
pkg/@eserstack/codebase/ajan-ffi-parity.test.ts ties all six declaration sites
together — the cgo exports in main.go, ffi/types.ts, the three native
backends and the WASM loader — plus a runtime check that the compiled library
exposes exactly the declared symbols, plus WASI dispatch names against what the
loader sends. Extractors are indentation-agnostic and floor-checked, so a
silently-empty parse reports itself instead of passing vacuously. It lives
beside ajan-ranges.test.ts, the existing precedent for a cross-cutting
invariant guard. Also landed from this entry's spirit: ffi/close-guard.ts is
now one shared implementation used by all three backends instead of three
hand-rolled copies.
Still open: the symbol table itself (a) and the mapped type over it (b). Note the constraint the guard now imposes — the loaders' symbol names must stay literal object keys, or the extractor needs updating in the same commit.
What: (a) A shared symbols-table.ts — {name, arity, nonblocking} for all
100 symbols (~120 lines); derive Deno's dlopen map, Bun's dlopen map, koffi's
prototype strings, and all wrapper functions from it by a loop. Each backend
shrinks to a ~60-100-line adapter
(open/encodeArg/call/callAsync/decodeAndFree/close). (b) Make types.ts's
570-line symbols interface a mapped type over the table. (c) A small generated
parity check: Go //export list vs table keys vs the main_wasi.go case list.
Why: The three backends total 3,085 lines (deno 1,403 / bun 972 / node 710)
of which ~90% is mechanical repetition: every symbol except four specials fits
()→string, (string)→string, or (string,string)→string. Today a new Go
export requires five hand edits (main.go, types.ts, three backends — plus
main_wasi.go and the WASM loaders), and nothing machine-checks the agreement:
createSymbolWrappers takes rawSymbols: any (backend-deno.ts:471-474), so a
missing declaration compiles cleanly and explodes at runtime; the Bun/Node maps
have no compile-time tie to types.ts at all. Today's 100-way agreement is real
but hand-kept. Not just line count: the Bun GC bug and the decode-throw leaks
(below) exist precisely because each backend hand-rolled its own encode/free
helpers instead of sharing one audited implementation.
Context: Extends P1 — One memoized FFI client (which already names the manifest idea) and the "99-symbol C ABI restated by hand in 7-8 files" line in P2 — Pick one owner per duplicated domain. Do the memoized client and this in one motion — same seam, same owner.
Effort: M
Depends on: nothing; unblocks the parity check and shrinks every future export to a one-line table edit
Done (2026-08-09). The native error is captured, passed through debugLog,
carried as the Error cause, and named in the combined failure message
alongside the WASM cause. An explicitly-passed libraryPath that fails to open
now rethrows instead of falling back. The documented-but-nonexistent backends
option was implemented rather than deleted, and selectBackend's doc corrected
to match what it does.
What: Capture the native error, debugLog it, include it in the combined
failure message, and rethrow (no WASM fallback) when the caller passed an
explicit libraryPath.
Why: ffi/mod.ts:264-273 discards: resolveLibraryPath's carefully built
checked-paths message (resolve.ts:231-238), Deno --allow-ffi permission
errors, dlopen failures for a found-but-wrong library (arch mismatch, ABI
drift), and selectBackend's own error. A real native error silently degrades
to WASM — which per the P1 entry above may be broken-at-runtime — and when WASM
also fails, the thrown message claims "No native FFI backend available"
reporting only the WASM error. Even ESER_AJAN_DEBUG=1 cannot surface the
native cause (the catch doesn't log). An explicit libraryPath failing to open
almost certainly should not fall back at all.
Effort: S
Done (2026-08-09). Resolution now walks node_modules ancestors upward from
the module directory, so the hoisted scope-sibling layout resolves regardless of
cwd, with checkedPaths still listing every location actually probed in probe
order. The node:fs top-level await is deferred into fileExists (memoized),
so a runtime without node:fs can now reach the WASM fallback that exists for
it instead of failing at import time — which also removes a no-top-level-await
lint exception. Stale comments corrected; the JSR constraint is documented.
What: Walk node_modules ancestors from moduleDir (or add
${moduleDir}/../.. as a scope-sibling root; import.meta.resolve /
require.resolve is the robust option under Node/Bun). Defer the node:fs
import into fileExists.
Why: When installed from npm, this module lives at
<proj>/node_modules/@eserstack/ajan/ffi/ and the optional platform package is
hoisted to the sibling <proj>/node_modules/@eserstack/ajan-<slug>/ — which
equals ${moduleDir}/../../ajan-<slug>/ and is never checked:
resolve.ts:208-217 tries only moduleDir, pkgRoot, and cwd as
node_modules roots. So the standard npm layout resolves only when the
process cwd is the project root — a CLI invoked from any subdirectory falls
through to system paths, then errors (or silently drops to WASM via the bare
catch above). pnpm's isolated layout misses all three roots too.
Context: Also in resolve.ts: the top-level await import("node:fs")
(resolve.ts:18) is reached via mod.ts's static import, so on any runtime
lacking node:fs the whole entry module fails at import time — before the WASM
fallback (the branch meant for exactly such runtimes) can run. Comment nits:
resolve.ts:126 names the package @eserstack/eser-ajan-{platform} (code uses
@eserstack/ajan-{slug}); JSR installs have no optionalDependencies and
remote-cached modules have no import.meta.dirname, so JSR effectively requires
ESER_AJAN_LIB_PATH — worth documenting.
Effort: S
Done (2026-08-09). ffi/close-guard.ts — one implementation wrapping all
three backends. close() is idempotent; any symbol called afterwards throws an
Error naming it instead of segfaulting inside a library that is no longer
mapped. Wrappers preserve arity deliberately, since arity is part of the ABI
contract. The two things it does not enforce are documented at the top of the
module: close() does not call EserAjanShutdown, and Deno's nonblocking
symbols may still be on the FFI threadpool, so callers must settle promises and
close streams first. In-flight tracking was deliberately not implemented.
What: A closed flag on all three backends (throw a friendly error on
use-after-close / double-close), plus either an in-flight counter or a
documented "close only after all streams are closed and promises settled"
contract.
Why: All three backends (backend-deno.ts:1398-1401,
backend-bun.ts:967-969, backend-node.ts:705-707) call the runtime's
close()/unload() unguarded. Double-close is whatever the runtime does (Deno
throws BadResource; koffi unload of a Go c-shared library is undefined
behavior — a Go runtime cannot be dlclosed safely, which is exactly why
pin_image_posix.go exists). Any symbol call after close() is a UB/segfault
path. On Deno, nonblocking: true symbols may still be executing on the FFI
threadpool when close() runs — a crash window. Nothing tracks outstanding
stream/PTY/keypress handles at close time.
Context: In practice no TS consumer ever calls lib.close() (see the
Init/Shutdown note under P2 — Split bridge.go), so this is latent — but the
API is public.
Effort: S
P2 DONE — bridge.go handle hygiene: stream-close hang, model use-after-close, inconsistent semantics
Done (2026-08-09). Stream close now closes the body first, outside the read
mutex (TestHttpStreamCloseDoesNotWaitForAStalledRead blocks for 5s against the
old code). Models got the streams' proven acquire/release + delete-then-Wait
shape via a by-handle sync.WaitGroup side map — a state struct was not
possible because bridge_ai_cancel_test.go assigns models into the registry
directly. Close is now uniformly idempotent-success, the safe direction; read
sentinels were deliberately left alone and now carry a comment saying so.
Per-handle mutexes added for stream reads and tokenizer push.
CloseIdleConnections reaches the inner transport (see the status note — the
obvious form is a silent no-op). Negative config clamped via
countOrDefault/durationOrDefault. The hand-rolled .git/HEAD parser is gone
in favour of codebasefx.GetCurrentBranch.
What: Batch of related registry/handle fixes in bridge.go:
bridgeHttpStreamClosewaits onentry.mu(bridge.go:1691) whichbridgeHttpStreamReadholds across the blocking network read (bridge.go:1639-1657) — closing a stalled SSE/long-poll stream blocks the FFI thread until the server sends bytes.http.Response.Body.Closeis safe concurrent withReadand unblocks it: close the body first, outside the read mutex. Same pattern inherited bycloseAllHandles(bridge.go:546-555).- Model use-after-close:
bridgeAiGenerateText/StreamTextcopy the model out underRLock(bridge.go:631-633, 663-665) and a concurrentbridgeAiCloseModel(743-776) can close it mid-generation. Streams got in-flight protection (streamState.wg— correctly ordered, keep it); models got none. - Inconsistent unknown-handle / double-close semantics: stream reads return the
completion sentinel
"null"for a missing handle (bridge.go:712-714, 4566-4568, 4697-4699) so use-after-free is indistinguishable from end-of-stream, while exec/PTY reads return an error JSON (5275-5277, 5412-5414); double-close errors for model/http/log/pty/exec/tokenizer/keypress but is silently idempotent foraiFreeStream(793-795),cacheClose(3220-3231),postsClose(3914-3918), and the walk/validate stream closes. Pick one contract. - Per-handle operations without per-handle locks: concurrent
bridgeAiStreamReadon one handle callsiter.Next()concurrently (bridge.go:717); concurrenttokenizerPushcallstok.Pushconcurrently (bridge.go:4985). httpStream got a per-entry mutex; these did not. - Small ones:
bridgeHttpClose(1483-1496) drops the client withoutCloseIdleConnections(pooled TCP lingers); negative numeric config becomes huge unsigned (uint(req.FailureThreshold)/uint(req.MaxAttempts), bridge.go:1333, 1344; negativeTimeoutMsunclamped).
Effort: S each; do alongside the P2 bridge.go split
Partially addressed (2026-08-09). The LIMITATION comment that only the PTY
wrappers carried now also sits on the generate/stream/batch wrappers in both
backends, so the constraint is visible where it bites. The behaviour is
unchanged — under Bun and Node these calls still park the event loop and
EserAjanAiCancelRequest still cannot fire mid-call. Making them genuinely
async (bun:ffi threadsafe, koffi async, or a worker) is the remaining work.
What: Use Bun's FFIFunction.threadsafe/async support for the 9 long-
running symbols (AI generate/stream/batch, PTY read); for koffi, either its
async call mode or a worker. At minimum, extend the LIMITATION comment that the
PTY wrappers already carry to the AI wrappers, and note it where
types.ts:41-56 promises cancellability.
Why: Under Bun and Node all AI calls are synchronous-blocking
(backend-bun.ts:485-550, backend-node.ts:403-444) — a minutes-long
generateText parks the entire event loop, and EserAjanAiCancelRequest can
never fire mid-call because the abort listener cannot run. Only Deno marks these
nonblocking: true (backend-deno.ts:51-65). Only the PTY-read wrappers
document the limitation (bun 935-940, node 682-688); the same caveat applies to
generate/stream/batch.
Effort: S (document) / M (actually async)
Done (2026-08-09). try/finally around the free in all three decode
helpers. koffi version message corrected to the pinned range; the stale symbol
count rephrased so it cannot go stale again. The backends option was
implemented (see the loader entry above) rather than removed from the docs.
Commit.body no longer claims to be a required string when Go emits it
omitempty.
What: Batch of small, independent items in pkg/@eserstack/ajan/ffi/:
try/finally { free(ptr) }in all three decode helpers — an exception during decode leaks the Go allocation: BunreadAndFree(backend-bun.ts:451-458), NodetakeString(backend-node.ts:77-86), DenoreadCString+freePtr(backend-deno.ts:457-479). Happy paths are leak-free (audited); these are exceptional-path one-offs.backend-node.ts:56-61error message claims koffi "^2.15.0" while package.json pins^3.1.4— misdirects anyone debugging a version mismatch. Alsobackend-node.ts:68says "96 symbols"; it's 97 malloc'd returns.ffi/mod.ts:34advertisesloadEserAjan({ backends: ["deno"] })butLoadOptions(mod.ts:62-67) has nobackendsfield — silently ignored.selectBackend's doc comment (mod.ts:170-197) describes parameters and flag-checking it doesn't have.codebase/git.ts:16-23typesCommit.bodyas requiredstringwhile Go emitsbody,omitempty(bridge.go:3966-3970) — empty-body commits yieldbody: undefinedbehind astringtype.
Effort: XS each
Partially done (2026-08-09). The two tests called out below as highest-value
both exist now: bridge_handle_safety_test.go carries a -race log
configure/write test and a validator-names assertion, each verified to fail
against the unfixed code. Also added: panic_guard_test.go, idempotent-close
and unknown-handle coverage across 10 close entry points, the stalled-read and
in-flight-generation tests, ffi/mod_test.ts, ffi/resolve.test.ts,
logger.test.ts, update.test.ts, client_ip_test.go, expanded
cors_middleware_test.go, and the ABI parity guard.
Still untested: AI batch, cache/cs/kit/posts/collector/noskills bridges,
shell exec/pty/tui (still nothing at all), the workspace checks, and the
main_wasi.go dispatch including extractStringArg.
What: Add coverage for the surface the bridge tests skip entirely. Well
covered today: AI cancel registry (incl. concurrency), AI wire mapping,
codebase/http/parsing stream leak gates, HTTP error/retry mapping, log
lifecycle, workflow shell steps, lifecycle smoke. Untested: all AI batch
functions; format encode/decode/document (Go side — the TS .ffi.test.ts covers
the TS client); cache, crypto, cs, kit, posts, collector, noskills bridges;
shell exec/pty/tui (nothing at all); non-stream codebaseValidateFiles (one
assertion on result names would have caught the P1 mislabeling); the workspace
checks; the entire main_wasi.go dispatch (extractStringArg included); and no
-race stress on any registry except the cancel table — which is exactly why
the log-handle race survived.
Why: The FFI seam's safety net is currently the consumer-side .ffi.test.ts
suites (Deno backend only) plus CI's ajan version smoke on three runtimes.
Everything else listed above ships on trust.
Effort: S per subsystem; prioritize a -race log configure/write test and
the validateFiles names assertion (both would have caught this review's P1 bugs)