Skip to content

fix: type the platform contract, and remove all five import cycles - #12

Merged
matej21 merged 2 commits into
refactor/audit-cleanupfrom
refactor/platform-contract-and-cycles
Aug 7, 2026
Merged

fix: type the platform contract, and remove all five import cycles#12
matej21 merged 2 commits into
refactor/audit-cleanupfrom
refactor/platform-contract-and-cycles

Conversation

@matej21

@matej21 matej21 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Stacked on #11 — the two items that PR's "Not in scope" listed as worth doing
next. Review #11 first; this targets that branch, so it retargets to main
automatically once #11 merges.

Typing the platform contract found a third divergence

standalone-server/platform-api.ts implemented 13 platform methods as
Record<string, Handler> with input: any, and referenced @roj-ai/client
only inside comments. The guarantee this package exists to provide — that the
platform client works unchanged against it — rested entirely on prose.

The map is now Partial<{ [M in PlatformMethodName]: Handler<M> }>, with input
and output drawn from the contract's own MethodInput/MethodOutput.
Partial is deliberate: bundles.*, sessions.publish, sessions.usage,
instances.archive and services.getUrl are unimplemented by design and still
fall through to method_not_found — now an explicit gap rather than a silent
one.

Wiring it up produced three compile errors immediately. The audit had found two;
the compiler found the third:

method shipped contract
sessions.create { sessionId } + status
sessions.list raw manager payload { id, presetId, status, createdAt }, createdAt as ISO
tokens.create { token: '' } + expiresAt

I checked the link actually holds rather than assuming it: renaming a field on
CreateSessionOutput breaks the standalone build. That is the whole point —
before this, the same rename produced a runtime method_not_found or a
wrong-shaped JSON body in whichever app happened to run locally.

Why the drift was invisible. sessions.list's manager method declared
output: { sessions: z.array(z.unknown()) } while its handler returns
SessionMetadata[]. The schema now says what it produces. callManagerMethod
is still typed Result<unknown> — the manager method registry is untyped,
unlike the plugin method registry — so platform-api validates the payload with
the now-exported sessionMetadataSchema rather than asserting it, which also
removes a pre-existing as cast.

All five import cycles are gone

Both were value-level, so the ESM module graph really did contain them — not a
type-shape artifact.

Four came from getServices (app.ts:49), a runtime function that
files.ts, resources.ts, rpc.ts and upload.ts all import, from the module
that imports each of them in order to mount them.
AppContext/AppEnv/AppServices are type-only and were never the problem.
All four now live in transport/http/context.ts, which imports nothing from
app.ts; app.ts re-exports them so from './app.js' keeps working.

The fifth was the worse one: workers/plugin.ts ↔ workers/context.ts, both
directions value imports, with workerEvents produced by a top-level
createEventsFactory call — so it depended on module initialisation order.
Moving the events and EmitEvent into workers/state.ts also puts the plugin
back on the convention mailbox, resources and uploads already follow.

Verified with a value-import-only cycle detector over all 419 source files
(type-only edges excluded, ~/* resolved): 5 → 0.

Verification

bun run ts:build clean. bun run lint unchanged (the one warning is
pre-existing in uploads/plugin.ts). bun test packages/*/src packages/*/tests
943 pass, 5 skip, 0 fail, same as #11.

No new tests here on purpose: the contract typing replaces a category of test
with the compiler, and the cycle work is a pure move with no behaviour change.

Follow-ups this unblocks

  • The standalone-server integration test from fix: harden runtime lifecycle, storage boundaries, and release safety #11's "Not in scope" is now
    smaller — the compiler already covers method names and payload shapes, so what
    is left to test is the transport, not the contract.
  • The manager method registry being typed Result<unknown> while the plugin
    method registry is fully typed is the next asymmetry worth closing. Every
    callManagerMethod caller currently validates or casts.

🤖 Generated with Claude Code

https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM

matej21 and others added 2 commits August 6, 2026 16:44
Both were value-level, not type-shape artifacts, so the ESM module graph really
did contain them.

Four came from `getServices` (app.ts:49) — a runtime function that files.ts,
resources.ts, rpc.ts and upload.ts all import, from the module that imports each
of them back in order to mount them. `AppContext`/`AppEnv`/`AppServices` are
type-only and were never the problem. All four now live in a new
transport/http/context.ts that imports nothing from app.ts; app.ts re-exports
them so `from './app.js'` keeps working for consumers.

The fifth was workers/plugin.ts <-> workers/context.ts, and it was the worse
one: `workerEvents` out of plugin.ts, `WorkerContextImpl` back out of context.ts,
both value imports, with `workerEvents` produced by a top-level
createEventsFactory call — so it depended on module initialisation order.
Moving the events and the EmitEvent type into workers/state.ts also puts the
plugin back on the convention mailbox, resources and uploads already follow.

Verified with a value-import-only cycle detector over all 419 source files
(type-only edges excluded, `~/*` resolved): 5 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM
… contract

platform-api.ts implemented 13 platform methods as
`Record<string, Handler>` with `input: any`, and referenced @roj-ai/client only
inside comments — the guarantee that "the platform client works unchanged
against the standalone server" rested entirely on prose.

The handler map is now `Partial<{ [M in PlatformMethodName]: Handler<M> }>` with
input and output drawn from the contract's own `MethodInput`/`MethodOutput`.
Partial is deliberate: bundles.*, sessions.publish, sessions.usage,
instances.archive and services.getUrl are unimplemented by design and still fall
through to `method_not_found` — now as an explicit gap rather than a silent one.

Wiring it up immediately produced three compile errors, i.e. three divergences
that had already shipped:

  sessions.create  returned { sessionId }, contract declares status too
  sessions.list    returned the raw manager payload, contract declares
                   { id, presetId, status, createdAt } with createdAt as ISO
  tokens.create    returned { token: '' }, contract declares expiresAt too

Verified the link works by renaming a field on CreateSessionOutput: the
standalone build fails, which is the whole point.

Two supporting changes:

sessions.list's manager method declared `output: sessions: z.array(z.unknown())`
while the handler returns SessionMetadata[]. That lie is why the drift was
invisible, so the schema now says what it produces. `callManagerMethod` is still
typed `Result<unknown>` — the manager registry is untyped, unlike the plugin
method registry — so platform-api validates the payload with the now-exported
sessionMetadataSchema instead of asserting it. That also removes a pre-existing
`as` cast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM
@matej21
matej21 merged commit b446ac1 into refactor/audit-cleanup Aug 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant