Architecture — how to think about changes
The whole codebase has one throughline: cross-cutting concerns live in the host; variants are config, not forks; reuse is type composition. Most "where does this go?" questions resolve by applying it.
Capability model (PRD §9.2)
Services reach infrastructure only through capability traits
(src/capabilities/mod.rs: FileStore, DataStore, QueryStore, HttpOut;
plus idempotency::IdempotencyStore and logging::LogStore). The host
constructs pre-scoped handles and hands them to a service via
ServiceContext — a service never picks its tenant or names a raw path:
ScopedFileStore::new(adapter, tenant)binds the tenant prefix.PrefixedFileStore::new(inner, root)roots every call under a prefix.ScopedFileStore::prefixed(prefix)composes both (tenant + subtree).
ServiceContext fields are the grants: Some(handle) = granted,
None = the capability never exists for that mount (default-deny). Sandboxed
code gets string-keyed grants through GrantedHost (src/contract/mod.rs),
also default-deny — an ungranted capability name fails capability_denied.
Adding a capability: define the trait in capabilities/ (or a sibling module
like logging), give Adapters a field + a with_* builder
(src/tenant.rs), thread it into ServiceContext, and grant it per mount in
Tenant::build. Default to a no-op/None so embedders and tests opt in.
Host choke points
Runtime::dispatch and Runtime::handle (src/runtime.rs) are the single
points every request passes through (internal pipeline calls route back through
handle too). CORS, universal caching, idempotency replay, the per-tenant
breaker, concurrency admission, and boundary logging all live here — not in
services. To add behavior that should apply to every response (a header, a log,
a policy), decorate at the choke point, not in each service.
Pitfall: anything you compute here runs on the hot path. Gate optional work
behind a cheap check (e.g. LogStore::enabled() skips building a record when
no sink is configured) so the G1 budget holds. See testing.md.
The store pattern (client polymorphism)
file, data, the spec stores, and the code store all obey one normative
contract pinned by tests/store_conformance.rs: trailing-slash dir+json
listings at every container level ({path, entries:[{name,dir,…}], total} +
X-Total-Count), PUT upsert (201/200, ETag), keyless POST → 201 + Location,
the 409 + ?confirm=<name> non-empty-container guard. Differences are declared
facets on the discovery surface (range, patch, schema, echo,
confirm-delete, content-addressed, static-site, …), feature-detected by
clients — never special-cased by service name. A new store-shaped surface joins
by passing the conformance suite and declaring facets; it does not invent a new
shape.
Spec stores (reuse by ownership)
SpecStore (src/services/spec_store.rs) is the model for reuse: it owns a
real FileService + a private ServiceContext (a PrefixedFileStore over
.rs2-pipelines{base} / .rs2-queries{base}, no other caps) and delegates —
validate → canonicalize → forward. The store contract is implemented once, in
FileService; pipeline/query authoring inherit it rather than re-implementing.
Compose private services through the type system (owning + decorators), not
through config.
Authoring uses a reserved dot-subtree (/<mount>/.pipelines/, /.queries/);
every other path on any verb executes the longest-prefix-matched spec
(.root governs the mount root — verb-passthrough wrapping). This replaced
v1's modal X-Restspace-Request-Mode header.
Variants are config, not new services
Before adding a service, ask whether it's a mode of an existing one:
- static-site =
fileconfig (defaultResource/spaFallback/listings). - caching = per-mount
cachingconfig applied host-side. - Named infra (S3, SQL) = an adapter selected by config (
"store": {...}), not a service fork.PrefixedFileStoreis the seam where named-infra plugs in.
Adding a prebuilt service
- Implement
Service(async fn handle(&self, msg, ctx) -> Result<Message>). mod+pub useinsrc/services/mod.rs.- Register the name in the
Tenant::buildmatch (src/tenant.rs); grant the capabilities it needs in theServiceContextthere. - Add a
pattern_ofarm + agent-surface/OpenAPI items insrc/discovery.rs. - If it's store-shaped, hold it to
tests/store_conformance.rs. - Document it in the skill (the
rs2-skillrepo'sreferences/services.md).
Instruction plane vs. data
A tenant's instruction plane is exactly: tenant config + .rs2-code/ +
.rs2-pipelines/ + .rs2-queries/ (well-known file-store prefixes);
everything else is data/assets. This separation is deliberate — it is the basis
for the future rs2 pull/push git workflow (git never runs in-process). Don't
mix operational/runtime state into these prefixes.
Embedding (PRD P2)
rs2-core has no global state. Embedders supply adapter trait impls and run
services in-process. Heavy dependencies are feature-gated (wasm, js,
http); the default build is the native engine only. Keep it that way: new
heavy deps go behind a feature.