Skip to content

Latest commit

 

History

History
213 lines (153 loc) · 11.3 KB

File metadata and controls

213 lines (153 loc) · 11.3 KB

Claude Code Rules for TinySystems

Thinking

  • Think through solutions completely before proposing
  • Consider edge cases and flow implications
  • Don't give half-baked answers that need revision when questioned

Code Style

  • Early returns, no nested ifs
  • Extract logic into small, focused functions
  • Flat structure over deep nesting
  • Idiomatic Go - if err != nil { return } pattern

CRITICAL: Handler Response Propagation

NEVER ignore the return value of handler() calls. ALWAYS return it.

Request-response subgraphs run blocking I/O: HTTP Server blocks waiting for responses to flow back through the handler chain. If any component ignores the handler return, responses are lost and requests time out. (Blocking vs durable delivery is derived per-subgraph from the SyncRPC capability — see below; the return-the-result rule applies in both modes, since in durable mode the Result drives ack/retry.)

module.Handler and Component.Handle both return module.Result. Construct it via module.Ok(value) or module.Fail(err). Chain handler calls back as the Handle return:

// WRONG — breaks blocking I/O, causes timeouts
_ = handler(ctx, "error", Error{...})
return module.Result{}

// CORRECT — propagates response back through call chain
return handler(ctx, "error", Error{...})

When emitting fresh data, wrap with Ok / Fail:

// Fresh success (no chained downstream): wrap the payload
return module.Ok(Response{StatusCode: 200, Body: body})

// Fresh failure
return module.Fail(fmt.Errorf("invalid request: %w", err))

// No-op success (system-port branches, ignored ports)
return module.Result{}

Only exceptions to "always return":

  • _reconcile port (internal system port, no response expected) — use the ReconcileHandler capability interface, not the legacy port branch
  • True fire-and-forget async operations launched from background goroutines (use Base.Emit and let the zero-Result no-op stand)

When writing components, always ask: "Does this handler call need to return a response to an upstream blocker?" If yes (which is most cases), return the handler result.

Declaring a Blocking Component: the SyncRPC Capability

If a component blocks holding a live connection — emits on a source port and synchronously waits for the downstream chain to deliver a result back within the same request — it MUST declare the module.SyncRPC capability:

// http_server holds the live HTTP connection until the chain returns a
// Response — declare it so the platform keeps its subgraph on blocking
// request/reply delivery.
func (c *Component) SyncRPC() module.SyncRPCInfo { return module.SyncRPCInfo{} }

One method, whole contract. module build auto-tags the component sync_rpc; the platform derives execution mode from it: the weakly-connected subgraph containing the component runs classic blocking request/reply, everything else runs durable (JetStream-persisted fire-and-forget). Nobody chooses modes — not users, not flows.

Forgetting this is fatal: durable hops return nothing to their sender, so the response your blocked goroutine waits on never comes back — the connection times out even with perfect handler-return discipline. Canonical implementer: http_server (live socket). Components that merely sit in its subgraph — a Slack command handler, a router — declare nothing; they inherit classic delivery automatically.

Same one-method pattern: module.AgentTool exposes a component as an MCP tool (agent_tool tag).

Output Shape: Where the Passthrough Context Goes

Two component roles, two shapes — keep them straight:

  • Trigger / source components (signal, cron, ticker) ORIGINATE the message: they emit the user-configured context AS the payload at ROOT. Downstream reads $.field.
  • Passthrough / mid-chain components (router, llm_router, delay, modify, async, pod_logs_get, llm_tools, ...) FORWARD a caller's context alongside their own output: they emit it NESTED under a context key. Downstream reads $.context.field, and the component's own results sit beside it ($.logs, $.messages, $.item).

If your component forwards a context, wrap it — handler(ctx, port, OutMessage{Context: in.Context, ...}) with json:"context" on the field. Emitting the bare Context value at root (the historical router bug) makes downstream edges read $.field, inconsistent with every other mid-chain node — and it validates green but resolves null the moment someone writes $.context.field.

Error Ports: the Recovery Boundary Pattern

Error ports are how flow authors define self-healing zones. They are the entire fault-tolerance story in TinySystems — there is no separate retry layer or durability primitive. A component author's job is to expose the right toggle and route failures consistently so the pattern composes across flows.

The contract authors expect:

  • Every component that can fail in ways an upstream caller might want to handle differently exposes a settings flag — by convention EnableErrorPort bool — and a corresponding source output port ErrorPort = "error" that appears in Ports() only when the flag is on.
  • When the flag is off and an error happens inside Handle, return module.Fail(err). The error bubbles up via Result.Err() through every intermediate component (each of which is following the "always return handler result" rule above) until it hits an enabled error port or the top of the flow.
  • When the flag is on and an error happens, route via the handler instead:
func (c *Component) handleError(ctx context.Context, handler module.Handler, reqCtx Context, err error) module.Result {
    if !c.settings.EnableErrorPort {
        return module.Fail(err)
    }
    return handler(ctx, ErrorPort, Error{
        Context: reqCtx,
        Error:   err.Error(),
    })
}

The two branches are not duplicates — they encode "let it bubble" vs "catch here and let the author wire recovery downstream from the error port".

The mental model is try/catch on the canvas. A chain A → B → C where C fails:

  • C returns module.Fail → B's handler(...) returns Result with Err() != nil → B returns that Result up (standard pattern) → A's handler(...) returns Result with Err() != nil → A decides.
  • If A has EnableErrorPort on and routes via the pattern above, the error fires out A's error port to whatever recovery flow the author wired.
  • If A doesn't, the error keeps bubbling up A's own caller.

Each enabled error port is a "catch" boundary. Everything between two error-port boundaries (or between an error port and the top of the flow) is a single transactional unit from the flow author's POV.

Practical rules for component authors:

  • Expose EnableErrorPort on any component that does external side effects (HTTP calls, DB writes, paid APIs, sends, etc.). Authors need recovery options for these.
  • Don't expose it on pure transforms (json encode/decode, template render). Failure in a pure transform is a programming error, not a runtime condition worth catching mid-flow.
  • The Error payload carries Context (so the recovery flow can correlate back to the original work) and an Error string (the failure message). Don't pack large unrelated data into Error structs — recovery flows usually need lean correlation, not the whole request.
  • Always return the handler result from the error-routing call — same propagation rule as success.

CRITICAL: System Port Delivery Order

System ports (_settings, _control, _reconcile, _identity) have NO guaranteed delivery order.

On pod restart or leadership change, _reconcile may fire before _settings. Components that persist state to metadata must guard against reconcile overwriting fresh in-memory values with stale metadata.

Identity Port

The _identity port delivers a v1alpha1.NodeIdentity struct with the node's resource name, namespace, flow, and project. Use it when a component needs to namespace local resources (e.g., filesystem paths on a shared PVC).

case v1alpha1.IdentityPort:
    id, ok := msg.(v1alpha1.NodeIdentity)
    if !ok {
        return fmt.Errorf("invalid identity")
    }
    c.storagePath = filepath.Join(os.Getenv("STORAGE_PATH"), id.NodeName)
    return nil

NodeIdentity fields: NodeName, Namespace, FlowName, ProjectName. Delivered once during reconciliation, like _client.

Pattern: use a guard flag to prevent stale overwrites:

type Component struct {
    settings         Settings
    settingsFromPort bool // prevents _reconcile from overwriting with stale metadata
}

// _settings handler — set the flag
case v1alpha1.SettingsPort:
    c.settings = in
    c.settingsFromPort = true
    // if component is active, also persist to metadata

// _reconcile handler — check the flag
func (c *Component) handleReconcile(...) {
    if c.settingsFromPort {
        return nil // don't overwrite user-provided settings
    }
    c.restoreFromMetadata(metadata)
}

Also: when active state changes settings (e.g. running cron receives new settings), persist to metadata immediately so subsequent reconciles don't clobber the update.

Durable Execution (v0.11.0)

Opt-in per node via label tinysystems.io/execution-mode: durable. The blocking model stays the default and must remain byte-for-byte unchanged — scheduler_blocking_io_test.go locks that contract.

How it works (contract, not implementation detail):

  • A business-port message arriving at a durable node MINTS a run (RunID); one carrying a RunID continues it. Identity rides the context (runner.RunFrom) from MsgHandler down to sendToEdgeWithRetry.
  • Durable emits publish fire-and-forget to the work-queue stream with Nats-Msg-Id = StepKey (no reply inbox). The acked unit is ONE HOP — handler returns after its emits are durably stored; the run continues on whatever pod consumes the next hop.
  • StepKeys MUST be deterministic across redelivery (per-edge sequence counters, fixed-length hash). Never derive them from time, randomness, or map iteration order.
  • The step ledger (Scoped(ScopeExecution, runID), key step/<stepKey>) is written AFTER the handler returns: record-exists ⇒ all its emits are stored ⇒ redelivery skips the component entirely. Failed steps are terminal records — the durability layer never re-runs business errors.
  • The run reconciler (scheduler, leader-gated) re-drives frontier hops of runs with no progress past staleAfter. staleAfter must exceed the worst-case single-step duration or a slow step can double-execute.

Component authors: emit-and-forward components need NO changes. Components whose logic depends on the downstream response value (http_server request/response) cannot run durable — they stay blocking front doors. Side-effectful components that must survive kill-DURING-call need a provider idempotency key; the ledger only covers kill-after-completion.

SDK vs Module Responsibilities

  • SDK handles serialization/deserialization
  • SDK handles metadata cleanup on state deletion
  • Components receive properly typed messages, not []byte
  • Components don't know about other modules' metadata keys

Workflow

  • Build and test before claiming something works
  • Tag SDK first, then update modules with new SDK version
  • Push changes proactively - don't wait to be asked

Communication

  • Be direct, not verbose
  • Run commands instead of asking user to run them
  • Don't claim things work without verification