Internal Contracts (Seam Interfaces)
July 18, 2026 · View on GitHub
The TypeScript definitions live in
packages/backend/src/domain/ports.ts; the fakes inpackages/testkit/src/(one file per port, barrelindex.tswithcreateTestKit()). This document is the reviewer-facing map: each port's responsibility, the spec §s it implements, and which Phase-1 work packages consume it.
Purpose
These ports decouple the backend's subsystems so Phase-1 work packages can build against
fakes (@pi-managed/testkit) in parallel before the real implementations land
(plan §4 dependency graph, §5 WP-0.10). Every Phase-1 package that touches one of these
subsystems programs to the interface and is tested against the fake; the real
implementation is swapped in later with a fake/real parity contract test
(plan §12 "Fake/real parity").
The §25.5 invariant (load-bearing)
Spec §25.5 names three trust boundaries; the harness (backend + AgentSession) cannot
see credentials, and the sandbox cannot either. These interfaces enforce that
structurally:
SecretStore.resolveBindingsForSessionreturns only opaqueSecretBindingrefs (a placeholder name like$MSB_GIT_TOKEN+ aSecretCredentialRef). It never returns a secret value. There is no field onSecretBindingthat holds a value.SandboxProvider.provision/registerSecretBindingaccept only those opaque refs; the provider implementation resolves the value host-side (writes it into microsandbox's host-side secret store). The harness code that constructs aProvisionSpecnever handles a raw secret.ProvisionSpec.envis for non-secret literal env vars only; JSDoc on the field and onProvisionSpecstates credentials MUST go throughsecretBindings.- The sole deliberate exception:
ProviderKeyResolver.resolveProviderKeysreturns raw model-provider API keys (§4.2). This does not break §25.5: those keys are decrypted host-side into the per-session in-memoryAuthStorageonly so the harness can make the model call — they are never registered as sandbox bindings and never visible to the guest.
Self-check (run after changes): grep -nE ':\s*string' packages/backend/src/domain/ports.ts
and confirm no method on SecretStore or SandboxProvider returns or accepts a raw secret
string. The only string returns on those interfaces are opaque IDs, placeholder names,
and credentialKey/mcpServerUrl locators — none of which is a secret value.
(ProviderKeyResolver is explicitly excluded from this check — see the exception above.)
Ports
SandboxProvider — the "Sandbox" abstraction
- File:
packages/backend/src/domain/ports.ts - Spec: §5.3, §5.4 (
provision), §10.1 (provisioning), §10.2 (exec/execStream), §10.3 (stop/start/snapshot/destroy, crash detection), §10.5 (network policy), §27.2 (tenant-namespaced names + labels), §12/§25.1 (secret bindings as$MSB_placeholders). - Methods:
provision,exec,execStream,stop,start,snapshot,destroy,reattachByLabels,status(incl.crashed),registerSecretBinding,metrics?(optional; live per-VM resource sample, §26.4, backsGET /v1/sessions/:id/metrics— absent on providers that front no VM, e.g. self-hosted). - Network policy:
NetworkPolicy = {mode:'unrestricted'} | {mode:'limited', allowedHosts}.unrestrictedcompiles to microsandbox'spublicOnly()preset (public internet allowed; private/loopback/link-local/cloud-metadata/host denied — notallowAll()).limitedcompiles to a default-denyNetworkPolicy.builder()plus explicitallowHost()rules (§6.2, Appendix A.1 #3). - §25.5:
ProvisionSpec.secretBindingscarry only placeholder names + credential refs — never values.registerSecretBindinglikewise. - Consumers: WP-1.3 (real impl, NAPI SDK), WP-1.4 (Operations adapter delegates
exec/execStream), WP-1.5 (session manager drives lifecycle), WP-1.2 (compile env →ProvisionSpec). - Fake:
FakeSandboxProvider— in-memory handles,setNextStatusfor crash simulation,scriptExecfor scripted output,registeredBindingsfor the §25.5 audit,scriptMetricsfor the §26.4 metrics sample.
SessionRuntime — the "Harness" abstraction
- File:
packages/backend/src/domain/ports.ts - Spec: §5.2 (
AgentSession), §5.4 (wake,getEntries,emitEntry), §6.3 (state machine), §9 (events). - Methods:
wake(sessionId),sendEvent(InboundEvent),subscribe()(async iterable ofOutboundEvent),interrupt(),getEntries(range?),status(). - Pi mapping: wraps
createAgentSession/AgentSession/SessionManager(Pidocs/sdk.md):wake≈createAgentSession({ sessionManager: open(jsonl) }),sendEvent≈prompt/steer/followUp+ custom-tool/confirmation flows (§9.4/9.5),subscribe≈session.subscribe(),interrupt≈abort(),getEntries≈SessionManager.getEntries()(positional slice). It does NOT reimplement Pi internals. - State machine:
idle → running → rescheduling → terminated(§6.3; 3 retries with exponential backoff). - Consumers: WP-1.5 (impl), WP-1.6 (sessions API calls runtime), WP-1.7 (events API +
SSE maps
subscribe→ wire), WP-1.8 (toolset config materializes into the session). - Fake:
FakeSessionRuntime— scripted outbound events, seeded entries, call recording.
SecretStore
- File:
packages/backend/src/domain/ports.ts - Spec: §12 (vaults, secret bindings), §12.5 (re-resolution), §25.1 (tokens never reach sandbox), §25.4 (egress substitution), §25.3 (MCP creds resolved by the proxy, not here).
- Methods:
resolveBindingsForSession(ctx) → SecretBinding[],revalidate(ctx) → void. - §25.5: returns only opaque
SecretBindingrefs. MCP creds (static_bearer/mcp_oauth) become refs the backend MCP proxy injects at request time (§25.3);environment_variablecreds become$MSB_placeholder bindings;git_tokencreds become host-scoped$MSB_GIT_TOKEN_<slug>placeholder bindings (§25.2). Callers MUST NOT log binding contents. - Consumers: WP-1.9 (impl), WP-1.5 (session manager calls
resolveBindingsForSessionand hands the refs toSandboxProvider), WP-2.4 (refresh/re-resolution). - Fake:
FakeSecretStore— scripted refs per session; holds no values.
ProviderKeyResolver
- File:
packages/backend/src/domain/ports.ts - Spec: §4.2 (per-session provider auth), R2.7 (fail-closed).
- Methods:
resolveProviderKeys(ctx) → Record<providerId → apiKey>. - Distinct from
SecretStore: returns the tenant's OWN model-provider keys (e.g. ansk-…API key), decrypted host-side into the per-session in-memoryAuthStorage. This is the sole port that hands the harness a raw credential (see the §25.5 exception above): the keys are NEVER registered as sandbox bindings and NEVER exposed to the guest — the harness holds them only to make the model call. - Fail-closed: an unresolved provider key aborts the session before any model call is made, so it can never fall through to a provider key in the host's own environment (§4.2/R2.7).
- Impl:
createProviderKeyResolver(domain/vault/provider-keys.ts), wired in the composition root (app.ts) and the session-worker entry. No testkit fake.
ObjectStore
- File:
packages/backend/src/domain/ports.ts - Spec: §28 (persistence), §21 (files), §13 (memory store contents).
- Methods:
put(streaming),get(streaming),conditionalPut(etag-based, for JSONL sync),delete,hardDelete(purge all versions, §13.6 redaction),head(single-object metadata, O(1), JSONL-sync etag recovery),list(async iterable),readonly versioningSupported. - Consumers: WP-P0.3 (impl: local fs + S3-compatible + GCS), WP-1.5 (JSONL sync), WP-1.12 (files API), WP-2.2 (memory stores), WP-1.3 (snapshots).
- Fake:
FakeObjectStore— Map-backed, conditional put enforces etag match.
UsageRecorder
- File:
packages/backend/src/domain/ports.ts - Spec: §9.7 (usage tracking), §6.3 (
budget_exhaustedstop), §26.2 (cost accounting), §8.12 (per-tenant rollup). - Methods:
record(sessionId, model, tokens),usdCost(model, tokens),cumulativeForSession(sessionId),checkBudget(sessionId, budget),rollupForTenant(tenantId, range?). - Token shape:
inputTokens,outputTokens,cacheCreationInputTokens,cacheReadInputTokens— recorded exactly as each provider reports them; cache TTL and pricing are provider-dependent (§9.7). USD via a per-model price table (config). - Consumers: WP-1.10 (impl), WP-1.5 (budget enforcement hook), WP-1.6 (sessions API exposes usage), WP-P0.4/§8.12 (tenant rollup).
- Fake:
FakeUsageRecorder— scriptablepriceTable,setBudgetBreachedfor thebudget_exhaustedstop, fallback pricing for unknown models.
Clock / Scheduler
- File:
packages/backend/src/domain/ports.ts - Spec: §17.8 (scheduler impl), §17.2 (literal wall-clock / DST semantics).
- Methods:
Clock.now(),Scheduler.tick(). - Why injectable: the cron loop's wall-clock semantics are literal (spring-forward
skip, fall-back double-fire), and exactly-once is enforced in Postgres (
(job_id, scheduled_at)unique), not in the loop.Clockis injectable so DST/double-fire tests run deterministically without waiting. - Consumers: WP-2.1 (scheduler impl), WP-2.5 (webhook dispatcher retry loop can reuse
Clock), any subsystem that reads wall-clock time. - Fake:
FakeClock(advance,set),FakeScheduler(setDue,onTickRun).
WebhookSink
- File:
packages/backend/src/domain/ports.ts - Spec: §23 (webhooks), §23.2 (thin payload).
- Methods:
dispatch(WebhookEvent). - Shape:
WebhookEvent = {type, id, createdAt}— thin payload only; recipients fetch the full object on receipt.event.idis unique per event, not per delivery. - Scope: this is the enqueue seam. The real dispatcher (retries,
X-Webhook-Signature, auto-disable, persisted queue) lands in Phase 2 (WP-2.5). Phase-1 subsystems emit lifecycle events through the sink without depending on the dispatcher. - Consumers: WP-1.5/1.6/1.7 (session lifecycle events), WP-1.9 (vault events), WP-2.5 (real dispatcher consumes the queue), WP-2.1 (job/run events).
- Fake:
FakeWebhookSink— records dispatched payloads. - Enriched payload (
data).WebhookEventcarries an optionaldata?: Record<string, unknown>, delivered verbatim in the payload. It is absent for every event type except the balance thresholds (below), which need the at-crossing balance that a later GET cannot recover. Existing thin payloads are byte-identical.
BillingSink — metering export seam (§11.4, WP-C5.2)
- File:
packages/backend/src/domain/billing/sink.ts(BillingSink,MeteringEvent). - Spec: console spec §11.4 (metering export). Wire schema
MeteringEventlives in@pi-managed/contracts(single source a sink consumer validates each delivery against). - Method:
recordMetering(MeteringEvent) → Promise<void>. At-least-once; MUST NOT throw (metering is best-effort — never blocks or fails usage recording). - Event shape. Aggregated + time-bucketed, never per-turn:
{ idempotencyKey, tenantId, bucketStart, bucketEnd, requestCount, inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens, totalTokens, usdCost }. Recipients dedup onidempotencyKey(stable per(tenant, bucketStartEpochMs)). - Producer —
MeteringAggregator(domain/billing/metering.ts): the recorder'sonMeteringhook. It sums each model request into a per-(tenant, bucket)accumulator (bucket widthBILLING_METERING_BUCKET_MS, default 60 s) and, on its flush loop, emits one aggregated event per closed bucket AND (whenBILLING_ENABLED) drains the ledger a matchingdebit— the usage→debit bridge that powers §11.1 enforcement and the §11.6 threshold events. Idempotent both ways (export dedups onidempotencyKey; the debit on the ledger's UNIQUE key). The accumulator is in-memory:flushAll()drains it on graceful shutdown; a hard crash loses at most one un-flushed bucket window (bounded under-count, never an over-charge). - Impls:
NoopBillingSink(default; billing disabled) ·WebhookBillingSink(HTTPS +X-Webhook-Signature, SSRF-pinned egress). Selected bycreateBillingSinkfromBILLING_SINK. Additional sinks (e.g.stripe) are additive implementations.
EmailSender (console spec §11.1 — WP-C5.1)
- File:
packages/backend/src/domain/ports.ts(EmailSender,OutgoingEmail). - Spec: console spec §11.1 (trial email-verification).
- Method:
send({ to, template, vars }) → Promise<void>.templatenames a server-side template;varsare its substitutions (the verificationtoken/ link). No credential is ever passed through this seam — the provider's API key is the sender impl's own config, never the harness's. - Default impl:
NoopEmailSender(domain/email/noop-email-sender.ts) — the dev/test default (and the composition-root default viaNOOP_EMAIL_SENDER). It delivers nothing and records each message in.sentso a dev flow / integration test can read the verification token (mirrorsNOOP_BILLING_SINK). - Real-sender contract: a production saas deployment injects a real sender (SES /
Postmark / SMTP) as
createApp({ emailSender }). The impl MUST: delivertemplate+varstoto; never throw for a transient failure in a way that fails sign-up (provisioning swallows send errors — the tenant can resend); and keep the provider SDK + credential entirely inside the impl (§25.5 posture). It is env-configured; the WP that ships a real sender adds itsEMAIL_*env vars. - Consumers:
domain/billing/trial.ts(provisionTrial/resendVerification), wired throughonboardingRoutes+billingRoutes.
Machine credit-surface (console spec §11.7 — WP-C5.1)
The narrow, MACHINE-authenticated seam the billing adapter (packages/billing-adapter,
WP-C5.3 — a separate process) calls to credit a tenant's ledger after a payment
webhook. It is an internal channel, not a public /v1 route, and it is NOT a
tenant API key — it can credit any tenant, so it never rides the tenant auth path.
- Wire:
POST /internal/billing/credit. Body:{ tenantId, amountMicros (positive int µUSD), idempotencyKey, source?, metadata? }. Response200 { entryId, applied, lifecycle, balanceMicros, balanceUsd }. - Auth:
Authorization: Bearer <token>where<token>is the per-deployment shared secretBILLING_PROVISION_TOKEN(config). Constant-time compared (SHA-256 digest +timingSafeEqual), the host-agent auth pattern (infra/sandbox-host- pool/auth.ts). Fail-closed: an unset/blank secret rejects EVERY request401. OnPUBLIC_PATHSso the tenant bearer-auth hook skips it; the plugin does its own machine auth in a preHandler (a tenant API key →401). - Idempotency (the money invariant, §11.7/§13): the same
idempotencyKeycredits the ledger EXACTLY once under webhook replay — enforced by the ledger'sUNIQUE (tenant_id, idempotency_key)constraint; a replay returns the existing entry withapplied: falseand no balance change. - File:
packages/backend/src/api/billing-internal.ts(billingInternalRoutes,BILLING_CREDIT_PATH). Credit lands viadomain/billing/ledger.tsappendEntry.
Adapter internal surface — checkout / portal / auto-charge (console spec §11.7–11.8 — WP-C5.3 F4)
The REVERSE machine channel of the credit-surface: the adapter's inbound HTTP surface
that the backend's SDK-free /v1 link-out proxy calls to reach the payment engine. It
closes the W15 top-up + auto-charge-controls gap. Internal channel, not a /v1
route, same trust posture as the credit-surface (a shared machine bearer, NOT a
tenant key).
- Direction: backend → adapter (the credit-surface is adapter → backend). The backend proxy resolves the tenant from its own auth, then forwards the tenant id.
- Wire (
packages/billing-adapter/src/internal-api.ts):POST /internal/adapter/checkout{ tenantId, amountUsd }→{ url }(the reference engine needs an explicit amount; the adapter does the ONE USD→micros conversion at the engine boundary, §11.9).POST /internal/adapter/portal{ tenantId }→{ url };409when the tenant has no saved payment method (NOT404—404means "no adapter").GET /internal/adapter/auto-charge?tenantId=…→ contractAutoChargeConfig; an unconfigured tenant returns a default-OFF config (200, never404).PATCH /internal/adapter/auto-charge{ tenantId, enabled?, thresholdUsd?, amountUsd? }→ the updated contractAutoChargeConfig.
- Auth:
Authorization: Bearer <BILLING_PROVISION_TOKEN>— the SAME per-deployment shared secret, constant-time compared (SHA-256 +timingSafeEqual), fail-closed (unset/blank secret or a tenant key →401). - Backend proxy (
packages/backend/src/api/billing.ts): the four/v1/tenant/billing/{checkout,portal,auto-charge}routes forward here whenBILLING_ADAPTER_URL+BILLING_PROVISION_TOKENare set; unset ⇒404(the console's no-adapter state, §11.8). An adapter4xxsurfaces with its status; a401/5xxor unreachable adapter collapses to502. No payment SDK enterspackages/backend; these routes issue URLs / read-write config only — no money math, no ledger write.
Balance-threshold events (console spec §11.6 — WP-C5.2)
Webhook event types tenant.balance_low (threshold BILLING_LOW_BALANCE_THRESHOLD_MICROS,
default $2, global config) and tenant.balance_exhausted fire when a ledger debit CROSSES
the line — the tenant-notification half of the prepaid model, consumed alike by the console
banner, email, and the auto-charge engine (WP-C5.3).
- Seam:
domain/billing/threshold.tsappendEntryWithThresholdEvents(pool, eventSource, input, thresholdMicros)—appendEntry+ crossing detection + emit through the existingWebhookEventSource. Fires exactly once per crossing (detectBalanceCrossingscompares the applied entry's pre/post balance;balance_lowonly while post > 0, so a debit straight to ≤ 0 fires onlybalance_exhausted). A ledger idempotency-key replay (applied: false) emits nothing. The metering aggregator's drain is the production caller. - Payload: carries
data: TenantBalanceEventData(@pi-managed/contracts) —{ entryId, tenantId, balanceMicros, balanceUsd, thresholdMicros, thresholdUsd, lifecycle }— the at-crossing balance a consumer needs without a round-trip.entryId(the causing ledger debit) is the crossing's stable identity: the auto-charge engine derives its charge idempotency key from it (autocharge:<entryId>), so a redelivered event charges the card at most once (WP-C5.3 F1). Delivery is tenant-scoped (enqueued only to the tenant's own webhooks), so events are cross-tenant isolated.
Billing adapter — payment-engine seam (console spec §11.7 — WP-C5.3)
packages/billing-adapter (@pi-managed/billing-adapter) is the payment engine
(Stripe reference), a separate process — the Stripe SDK is imported in exactly
one file there (src/stripe-engine.ts) and appears in no other package (grep-asserted).
It consumes the two backend seams above; it never rides a /v1 route.
PaymentEngineseam (src/types.ts):createCheckoutUrl/createPortalUrl(hosted URLs the console links out to — the console never sees a card, §11.9),verifyWebhook(throws unless the signature verifies),chargeOffSession(auto-charge). Stripe lives behind this one interface; a different engine is a second impl. The single ledger-idempotency-key derivation (creditKeyForPayment(paymentRef) → "stripe:<id>",src/credit-key.ts) is shared by the webhook consumer and the auto-charge engine, so a payment credits exactly once no matter how many times, or through which path, it arrives.- Consumes the machine credit-surface via
HttpLedgerClient(host-agent bearerBILLING_PROVISION_TOKEN, real HTTP) — the sole way a payment reaches the ledger. - Consumes
tenant.balance_lowviaAutoChargeEngine.onLowBalance— opt-in/off by default; hard per-day and per-month caps (never exceeded); auto-disable + notify after N consecutive failures. Saved-method + off-session charging are adapter- internal; every auto-charge lands in the ledger like any top-up. The charge idempotency key is derived from the crossing's stableentryId(autocharge:<entryId>, WP-C5.3 F1), so a redeliveredtenant.balance_lowcharges once;onLowBalanceis serialized per tenant (an in-process keyed mutex, F2) so concurrent crossings never race the cap (a multi-process store must also reserve transactionally). - Exposes the internal surface (
src/internal-api.ts) the backend proxy calls for checkout / portal / auto-charge — see "Adapter internal surface" above. - Config is env-sourced and fail-closed (
src/config.ts) — seedocs/deploy.md§"Billing adapter". No credential ships in the package; tests supply none (they inject a fake engine or a locally-generated, obviously test-only signing secret).
Supporting types
All in ports.ts: SessionId, TenantId, SessionContext, EntryRange, TimeRange,
SecretCategory, SecretCredentialRef, SecretBinding, NetworkPolicy, VolumeMount,
ResourceLimits, ExecOptions, ExecResult, ExecChunk, SandboxStatus,
SandboxMetrics, SnapshotId, SandboxHandle, ProvisionSpec (incl. optional
repos?: RepoCloneSpec[]), RepoCloneSpec, SessionEntry, InboundEvent,
OutboundEvent, SessionStatus, ObjectMeta, PutResult, TokenCounts, Usage,
Budget, BudgetCheck, WebhookEvent.
These are minimal — only what the ports need to type their method signatures. The
full wire contracts (event payloads, request/response bodies, error envelopes) live in
@pi-managed/contracts (WP-0.9). Where a port carries an event payload, it is typed
unknown at the seam and narrowed by the contracts package; do not duplicate those
shapes here.
Wiring note (monorepo)
@pi-managed/testkit declares a workspace dependency on @pi-managed/backend so the
fakes can implements the port interfaces (parity is checked at compile time). To run
pnpm --filter testkit typecheck standalone, build the backend first
(pnpm --filter backend build) so its dist/ type declarations exist; pnpm -r build
and pnpm -r test do this in topological order.