Skip to content

Constructs Paradigm: Everything Is a Construct ​

  • Status: Draft
  • Impact: High — changes the core model of @geekmidas/constructs, @geekmidas/manifest, @geekmidas/cli, and @geekmidas/cloud
  • Breaking: as shipped in v10, the free-standing builders (e, f, c, s, q, t), api.endpoints, .publisher(…) and the workspace services: { db, cache, mail } block are removed rather than deprecated. .services([…]) remains on the builders as the legacy path; .dependsOn([…]) replaces it. (The draft planned four warn-and-honour deprecations instead — superseded.)

Where this landed (v10). This is a design record; the authoring sketches below (t.name(…), q.name(…), userEvents.name(…).on(…), a queue's send()) were proposals, and where they differ from what shipped they are marked superseded. The API today:

ts
export const api = new RestApi('Api', { path: 'apps/api', defaultAuthorizer: 'none', logger });
export const router = api.database(database).auditor(AuditStorageService);
export const worker = new Worker('Jobs', { logger }).database(database);

export const users = new Topic('Users', { events: { 'user.created': schema } });

export const createUser = router.post('/users')
  .dependsOn([emails])
  .event(users, { type: 'user.created', payload: (u) => ({ userId: u.id }) })
  .handle(async ({ services }) => {
    await services.emails.publish([{ type: 'Emails', payload }]);
  });

export const emails = worker.queue('Emails').message(schema).handle(…);
export const onUser = worker.topic(users).subscribe(['user.created']).handle(…);
export const cleanup = worker.cron('rate(1 day)').handle(…);

Overview ​

Today a construct means a function-shaped thing that gets deployed — an Endpoint, Cron, Function, Subscriber, or Queue — plus one outlier, Topic. Everything stateful an app depends on (a bucket, a database, a cache, a secret) isn't a construct at all. It's declared separately, three times over, in three places that agree only by string convention.

This proposal makes everything a construct, and introduces one primitive tying them together: the dependency edge. From a single edge the framework derives a function's environment and its runtime binding; a target adapter separately derives cloud access at deploy. Application code becomes the source of truth for infrastructure, and the manifest becomes the seam between build and run.

Declare a thing once, in code. The runtime client, the deployed infrastructure, and the local dev container all derive from that declaration.

Problem Statement ​

1. Every resource is declared three times ​

ConcernWhereHow
Runtime clientapp codea hand-written Service reading UPLOADS_NAME, building an AmazonStorageClient
Deployed infrasst.config.tsnew Storage(stack, 'uploads') + links: [uploads], envVars: ['UPLOADS_NAME']
Local infragkm workspace configservices: { … } → a minio container in the generated compose file

The only thing holding them together is the literal string UPLOADS_NAME, typed by hand in each. Rename the bucket and nothing fails at compile time.

2. Environment is inferred, not declared ​

Because resources are opaque hand-written Services, the build can't know what config a function needs — so it runs the service to find out. Construct.getEnvironment() calls each service.register() against a SnifferEnvironmentParser and a fake ServiceContext, recording which keys get touched.

The machinery it needs is the tell: a noop context, a module-level cache keyed by service object (because singletons short-circuit on the second register()), sniffWithFireAndForget to swallow background rejections, and a catch that logs and returns [] — so a service that throws early yields an empty env list and a green build.

3. The manifest can't describe infrastructure ​

Manifest has routes, functions, crons, subscribers, queues, topics — six kinds of function, zero kinds of resource. It can say what code to run, not what to provision. That gap is why sst.config.ts still hand-declares buckets.

4. Provider words leak into a provider-neutral layer ​

queues means SQS. topics means SNS. subscribers carries transport?: 'topic' | 'queue'. AWS transport names sitting in what should be a portable contract.

5. Packages are coupled to things you don't use ​

@geekmidas/constructs marks every @geekmidas/* peer optional, but two levels below that it still binds:

ts
// packages/constructs/src/Construct.ts:1-11
import type { AuditStorage } from '@geekmidas/audit';
import { SnifferEnvironmentParser, sniffWithFireAndForget }   // ← VALUE import
  from '@geekmidas/envkit/sniffer';
import type { EventPublisher, MappedEvent } from '@geekmidas/events';

The root export has a runtime dependency on envkit, so importing even ConstructType resolves it. And the base is generic over nine type parameters — logger, event publisher, output schema, services, audit storage and its service name, database and its service name — so merely naming the type requires audit, events, logger, services, and @standard-schema/spec installed, whether or not you audit or publish anything.

6. The pattern is already here, ungeneralized ​

Topic and Queue already derive their producer-side Service rather than making you write one — name is the source of truth, env key computed from it, client derived. This proposal is largely: do that for everything, and make the derivation visible to the build instead of only to the runtime.

The Model ​

One interface, not a base class ​

ts
export interface Construct<TName extends string = string, TClient = never> {
  readonly id: TName;
  declare(): Declaration[];
  readonly service: [TClient] extends [never] ? never : Service<TName, TClient>;
}

export namespace Construct {
  export type Infer<C> = C extends Construct<any, infer T> ? T : never;
  export type Name<C>  = C extends Construct<infer N, any> ? N : never;
  export function ref<C extends Construct>(id: Name<C>): C;
  export function fromService<S extends Service>(service: S): Construct<…>;
}

An interface, so Endpoint, Cron, Queue, and Topic satisfy it without inheritance surgery, fromService can return a plain object, and .dependsOn() gets enforcement structurally — a Service ({ serviceName, register }) simply doesn't match { id, declare, service }, with no instanceof anywhere. Statics come from declaration merging.

A construct is simultaneously an infrastructure requirement (build time), a runtime capability, and something other code can consume. That forces exactly three members: declare(), id, and service.

The existing constructs are not special ​

Endpoint, Cron, Function, Subscriber, Queue, and Topic conform to the same interface as everything else, in Phase 1 — not as a late retrofit. If the interface can't express Endpoint (session, authorizer, audits, RLS, output schema), it's the wrong interface, and that's worth learning before four resources are built on it.

declare() returns a discriminated union ​

ts
type Node = { id: string; provides?: string[]; requires?: string[] };
type Fn   = Node & { handler: string; dependencies: Dependency[] };

export type Declaration =
  | Node & { kind: 'objects';  versioned?: boolean }
  | Node & { kind: 'database'; engine?: 'postgres' }
  | Node & { kind: 'database-reader'; of: string }
  | Node & { kind: 'database-schema'; of: string; schema: string }
  | Node & { kind: 'cache' }
  | Node & { kind: 'table' }
  | Node & { kind: 'secret' }
  | Node & { kind: 'topic'; events: string[];         subscribers: (Fn & { events: string[] })[] }
  | Node & { kind: 'queue'; fifo?: boolean;           worker: Fn }
  | Node & { kind: 'rest-api';
             authorizers: string[];      // names only — resolved by the cloud implementation
             endpoints: (Fn & { method: HttpMethod; path: string; authorizer: string })[] }
  | Fn   & { kind: 'cron'; schedule: string }
  | Fn   & { kind: 'function' }
  | Node & { kind: 'none' };                          // a `fromService` lift

A discriminated union gives exhaustiveness and per-kind fields, so there is no ConstructType enum to keep in sync and no shape that carries fields belonging to a different kind. declare() returns its declarations rather than writing into a mutable builder passed in — a value is easier to test and compose than a visitor.

Triggered functions nest inside the surface that triggers them, so there is no trigger field — position carries it. Fn is the shared shape for anything with a handler; Node for anything without.

declare() returns an array, since discovery emits each construct independently and the build assembles children into their parent.

Triggers are not dependencies ​

  • Trigger — what causes a function to run: a schedule, an HTTP route, a queue, a topic subscription.
  • Dependency — what it consumes while running.

Keeping them distinct removes the queue worker's self-edge, makes topic fan-out need no machinery, and explains an IAM asymmetry that would otherwise look arbitrary: a subscriber needs no permission on the topic at all — the subscription is created at deploy and at runtime it only reads its own queue. Only the publisher edge yields sns:Publish.

One construct, one service — the address it owns ​

.service is the address the construct owns. A construct contributing several declarations still has exactly one.

constructowns.service
ObjectStoragea bucketStorageClient
Databasea connectionrequest-scoped connection
Cachean endpointcache client
Topicthe topican EventPublisher — publish([{ type, payload }])
Queuethe queue URLan EventPublisher typed to its message
Functionan ARNtyped invoker
RestApia URLtyped caller
Subscribernothing — binds to another's topic—
Cronnothing — fired by a schedule—

The trigger-side half is never consumable: you can't call a queue worker, you send to its queue. With TClient = never, .dependsOn([dailyReport]) is a compile error rather than a stub that throws.

The edge is the one primitive ​

Because functions are consumable too, the edge covers both function → resource and function → function:

ts
.dependsOn([uploads])     // → S3 access + UPLOADS_URL
.dependsOn([sendEmail])   // → lambda:InvokeFunction + SEND_EMAIL_URL

From one edge, three owners derive three things:

DerivedBy whomWhen
the function's envthe frameworkgkm build
its runtime bindingthe frameworkfirst request
cloud access (IAM)the target adaptergkm deploy

Permissions are not in the manifest and not a framework concept. The manifest records only what is depended on; what that implies on a given cloud is the adapter's business.

Two constraints follow. Resources stay leaves — a resource may not consume another construct, which keeps the provisioning graph acyclic. And function edges can cycle, so the build needs a cycle check naming the loop.

Authoring ​

A function is authored by whatever triggers it ​

A surface is a construct that owns an address and vends the functions it triggers.

triggerauthor
an APIapi.post('/orders').handle(…) — no name; method + path is the identity
a topicworker.topic(users).subscribe(['user.created']).handle(…)
a queueworker.queue('ProcessOrder').message(schema).handle(…) — one consumer, so the queue and its consumer are one construct
a scheduleworker.cron('cron(0 6 * * *)').handle(…)
direct invokeworker.input(schema).handle(…)

What shipped retires every free-standing helper. Endpoints are vended by their API, and everything without a port by a Worker — the process that runs it, which is also where the logger comes from. A topic is declared with new Topic(id, { events }).

Superseded: the draft retired only s and e, kept f and c because their triggers aren't constructs, and kept t and q because they create the surface itself. It also had a topic vend its own subscribers (userEvents.name('SendWelcome').on([…])). What decided it was that a cron, a subscriber and a queue consumer all need to say which process runs them, and a Worker is that statement.

This makes constraints unrepresentable rather than enforced: an endpoint can't exist without a surface, can't belong to two, and can't subscribe to a topic it doesn't reference. There is no such thing as an endpoint shared by api and adminApi — if two surfaces need the same logic, extract a Function and have both depend on it, which is better anyway since the routes usually differ in authorizer and response shape.

Superseded back-compat plan: export const e = new RestApi('api') was to keep every existing e.post('/users') working, with s retiring and q.queue(name) becoming new Queue(name). v10 removed e, s and q outright; a queue is worker.queue(name).

.dependsOn([…]) takes constructs only ​

ts
.dependsOn([uploads, ordersDb])                 // ✅
.dependsOn([myService])                         // ❌ compile error
.dependsOn([Construct.fromService(myService)])  // ✅ lift it
.services([myService])                          // ⚠️ deprecated

It records the edge and dissolves each construct's .service into the handler's service record under its id. Constructs-only is deliberate: it confines sniffing to the deprecated .services() and the explicit lift, so retiring .services() retires implicit env inference with it — and rg fromService becomes the migration checklist.

Construct.fromService() wraps any Service: declare() contributes a none node, createClient() delegates to register(), env still sniffed. It's also the permanent home for dependencies with no infrastructure (a clock, a formatter), so the graph is complete rather than complete-except-the-boring-ones.

Identity ​

Every construct takes an explicit name — except endpoints, which have none.

ts
export const uploads      = new ObjectStorage('Uploads');
export const processOrder = worker.queue('ProcessOrder').message(schema).handle(…);
export const userEvents   = new Topic('UserEvents', { events: { … } });

export const createOrder  = api.post('/orders').handle(…);   // no name

An endpoint is the one construct where naming is pure friction, and the one that doesn't need it: nothing depends on an endpoint, so it is never an edge target, and it already has an identity — its method and path. POST /orders is unique within its API (the validation pass enforces that), stable, and meaningful. Changing it is an interface change, so the identity moving with it is correct rather than surprising.

That identity also supplies the Lambda description — POST /orders — which is what makes a console list of forty functions navigable.

Ids canonicalise to PascalCase. uploads, Uploads, user_uploads, and user-uploads are the same id — not a collision to detect but the same construct, so declaring two of them is a plain duplicate. From that one canonical form everything else derives:

derivationexample
service keyUncapitalize<Id>services.userUploads
env prefixenvironmentCase(Id)USER_UPLOADS_URL
cloud name{stage}-{app}-{kebab}, lowercaseprod-myapp-user-uploads

PascalCase earns its place at the type level: the service key is Uncapitalize<TName>, a TypeScript intrinsic, so there is no custom transform and no runtime twin to keep in agreement with it. The other two derivations are runtime-only and one-way — nothing type-checks against them, which is where a lossy transform is affordable.

Explicit names are what keep the type level intact. With a name, TName is a literal, so Construct.Name<C> is checkable and Construct.ref<typeof x>('Id') rejects a typo in the editor rather than only at the manifest-build reference check. Inferring ids from export keys was considered and dropped: it would have needed the call site to supply the key (.dependsOn({ x }) object shorthand) to recover the service type, and it still could not restore Name<C>.

On renaming. Renaming an id renames deployed infrastructure — cheap for a function, destructive for a bucket or database. An explicit name does not prevent that; a string literal is exactly as easy to edit as a variable. What would actually protect you is diffing the built manifest against the last deployed one and reporting the cause — noted in Open Questions as future work.

Configuration ​

One <NAME>_URL per construct; the protocol picks the driver ​

ts
const uploads = new ObjectStorage('uploads');
//  id           → 'uploads'
//  config key   → UPLOADS_URL
//  connect(env) → a StorageClient, driver chosen by the URL's protocol
//  aws target   → an S3 bucket, and the adapter writes UPLOADS_URL

This generalizes an existing convention rather than inventing one — a Topic's and a Queue's service (their publisher) reads a connection string and select transport by protocol (pgboss:// locally, sns:// deployed). One variable per construct to inject, rotate, and log.

UPLOADS_URLclient
s3://uploads?region=eu-west-1AmazonStorageClient
gs://uploadsGoogleStorageClient
s3://uploads?endpoint=http://localhost:9000MinIO — same client

The cloud is named exactly once, by the thing doing the provisioning. Never in application code, never in the construct. The abstraction already exists in @geekmidas/storage: a StorageClient interface at the root, AmazonStorageClient behind ./aws, and a StorageProvider enum already listing AWSS3 | GCP | AZURE.

The URL contract ​

This table is the seam between the adapter (which writes the URL) and the client (which parses it). Both sides implement against it independently, so it has to be pinned before either is built.

A construct may provide more than one URL when it genuinely owns more than one address. Serving a bucket's objects on a domain is the clearest case, and it is not purely an infra concern — it changes what the client returns, since a served object has a public address while an upload still presigns against the bucket.

Superseded shape. This section originally put that on the bucket, as new ObjectStorage('Uploads', { cdn: true }) providing a second UPLOADS_CDN_URL. That is arrangement A in "Why a separate construct, and what it costs", and it does not survive: a boolean on a bucket cannot express open-versus-signed paths, cannot express two surfaces over one bucket, and grows a second provides key that is only sometimes there — so every consumer branches on its presence. The two addresses are real; they belong to two nodes. FileServer declares both, and the served address is UPLOADS_SERVER_URL from the surface rather than a conditional key on the bucket.

What survives from the original argument, and still holds: the served domain should not sit on the cloud's assigned hostname, because CloudFront signed cookies only work when it shares a registrable domain with the app, and an assigned hostname changes if the distribution is replaced — turning any URL you emailed or cached into a dead one.

Whether several served buckets share one distribution with path-based origins or get one each is the adapter's call; the declaration is per-construct either way. Whether a bucket is served is structural and lives in code, while the distribution, origin access, cache behaviours, TTLs, and invalidation are the adapter's business.

kindschemeshapenotes
objectss3 / gss3://<bucket>?region=<r>?endpoint= for MinIO
file-serverhttpshttps://<host>the MinIO path-style base locally
databasepostgrespostgres://<host>:<port>/<db>?secretArn= when credentials resolve at connect
database (reader)postgressame, reader endpointseparate key — see Open Questions
cacheredisredis://<host>:<port>Upstash: rediss://
tabledynamodbdynamodb://<table>?region=<r>
queuesqs / pgbosssqs://?queueUrl=<url>pgboss://<pg-url> locally
topicsns / pgbosssns://?topicArn=<arn>matches today's Publisher.fromConnectionString
rest-apihttpshttps://<domain>custom domain, or the API's own URL

Every component of a URL is read off the resource, never from the stack and never from ambient environment. A bucket can live in a different region than the function that reads it, and AWS_REGION in a Lambda is the function's region — so omitting ?region= breaks cross-region silently, at runtime, in the one deployment where you'd notice last. The same applies to a database's host and port, a queue's URL, a topic's ARN.

That self-containment is the whole point of the contract: connect() builds a correctly-configured client from the string alone. The moment any part of it is inherited from context, the URL stops being sufficient.

The driver is pinned by the build, not a runtime import. A runtime-computed specifier can't be bundled — esbuild fails on it, or with a static map includes every driver, so an S3-only Lambda ships the GCS SDK.

driver resolution
gkm build --target=…the generated handler imports one driver; esbuild --alias:/--define: pins it
gkm devresolved at runtime from the protocol; nothing is bundled

gkm build already generates per-construct handlers and already passes --define: for secret injection, so this is a new use of an existing seam.

Structural in code, stage-varying at deploy ​

The test for any construct option: would this value differ between dev and prod? If yes, it isn't a construct option.

in codeat deploy
the API exists, its id, that CORS is ondomain, CORS origins
an authorizer named jwt exists, and its kindits issuer, audiences
the signature scheme (stripe)the signing secret
methods, paths, schemas—

Consistency with existing machinery, not new work: AppProps already carries domain/hostedZoneId, the workspace config has a per-stage domains map, and Route53Provider resolves zones. It closes at runtime through the same URL rule — api.service reads API_URL, so a cron calling the API works against localhost:3000 in dev and the real domain in prod with no branch in app code.

Authorizers ​

The construct declares only the names it exposes; the cloud implementation resolves them. Local and deployed can legitimately differ — a local JWKS against a real issuer, a stubbed signature verifier against Stripe's — so neither the configuration nor the mechanism belongs in portable code. .authorizer() already exists on the endpoint builder and RouteInfo.authorizer is already a name; what changes is that the name is now checked against the surface that declares it.

This costs nothing at the type level because an authorizer and a session are separate concerns: the authorizer decides whether a request gets through, .session() decides what the handler sees. So a name resolving to different mechanisms in different environments has no effect on handler types.

An authorizer is implemented as Hono middleware. That's the only form available on every target: API Gateway v1/v2 have native authorizers, Traefik has ForwardAuth, Cloudflare has Access, Vercel has edge middleware — each partial, and Traefik's ForwardAuth cannot see the request body, which makes signature verification unimplementable there. In-process middleware works everywhere and is bypass-proof, so it is the baseline rather than the fallback.

That matters because provider: 'server' is a real production topology, not a dev convenience — dev and the containerized build run the same process shape. If authorizers only existed as gateway configuration, every containerized deployment would ship unauthenticated.

Gateway-native authorizers stay available on aws, where they shed traffic before the Lambda runs. Edge enforcement elsewhere — Traefik ForwardAuth, Cloudflare Access — is an optimization to add later if it earns its place, not a prerequisite.

ts
export const api      = new RestApi('api',      { authorizers: ['jwt', 'iam'], default: 'jwt' });
export const webhooks = new RestApi('webhooks', { authorizers: ['stripe'],     default: 'stripe' });

The root default is required — explicitly 'none' if that's what's wanted — so an API meant to be authenticated can't ship open by omission.

There is no separate WebhookApi: a signature check is an authorizer. Verifying a Stripe HMAC is authorizing a request with a different scheme, and modelling it that way removes the raw-body problem too — the authorizer verifies the unparsed payload before the handler's schema parses it.

Every API generates a client ​

gkm openapi emits one document today. With authorizers declared per surface, securitySchemes and endpointAuth are per-API, so a merged document would be wrong, not merely inconvenient. Every rest-api gets its own spec and client, keyed by id — no exceptions, including webhook surfaces, where the client is what tests drive.

.gkm/openapi/api.ts        → paths, endpointAuth, securitySchemes, createApi
.gkm/openapi/webhooks.ts

Two consumers, one contract. In-app callers get api.service typed directly from the construct — it vends the endpoint builders, so it knows their types with no codegen. Out-of-app consumers (a frontend, another app across an API boundary) use the generated client. Both describe the same surface, so they're testable against each other.

A bug this depends on. @geekmidas/client's iam strategy is a silent auth bypass:

ts
// packages/client/src/auth-fetcher.ts
case 'iam': {
  // IAM signing requires the full URL and request config … For now, return empty
  return {};
}

AwsSigner.sign(url, init) is defined correctly and AuthStrategy accepts { type: 'iam'; signer }, but resolveAuthHeaders(strategy, scheme) never receives the url or init — so the branch can't sign and returns {}. Configuring a signer type-checks and requests go out unsigned: a 403 if the endpoint is protected, and quiet success if it isn't.

Plumbing url/init through isn't sufficient. SigV4 signs headers, so signing must run last, over the fully assembled request — including default headers that must fall inside the signature. Strategies therefore split into two phases:

  • header-producing (bearer, apiKey) — contribute headers during assembly
  • request-signing (iam) — an optional signRequest(url, init) run once on the final request

With that, consumers stop bypassing createApi to inject a signing fetch, and the heuristic of sniffing for an Authorization header to decide what not to sign goes away — endpointAuth already knows which endpoints are IAM.

Environment: declaration first, sniffing second ​

env(fn) = ⋃ declaredKeys(c)  for each construct c in .dependsOn()   // exact, static
        ∪ ⋃ sniff(c)         for each `fromService` lift among them // as today
        ∪ ⋃ sniff(s)         for each Service s in .services()      // deprecated

The first term is exact. As apps migrate, the sniffed remainder shrinks toward empty, and the catch → [] failure stops applying to anything the framework provides.

Runtime ​

Request-scoped clients ​

Most clients are singletons; a database client is not — it has to be the current transaction with RLS applied. That's why .database() exists today as a separate builder method and db as a distinguished context slot.

The answer is the one the repo already uses for the logger: a Proxy over AsyncLocalStorage that re-resolves per call.

ts
new Proxy(pool, {
  get: (_t, prop) => {
    const conn = currentConnection() ?? pool;
    const value = Reflect.get(conn, prop);
    return typeof value === 'function' ? value.bind(conn) : value;   // ← bind matters
  },
});

The transaction resolver is the execution wrapper, not the handler:

ts
await withRlsContext(pool, extractor(ctx), (trx) =>
  connectionStorage.run({ connection: trx }, () => handler(ctx)),
);

Both halves already exist — withRlsContext does set_config per key, and withTransaction beneath it is re-entrant (if (db.isTransaction) return cb(db)).

Three details are load-bearing:

  • Bind methods to the resolved connection. A method reading a # private field throws through a proxy; binding to the real target avoids it. We don't control Kysely's or Knex's internals.
  • Knex needs an apply trap too, because its instance is callable. Miss it and db.raw(…) works while db('orders') breaks.
  • db.$pool escape hatch, for work that must survive a rollback. Without it, people write a second service and the abstraction leaks anyway.

Outside a request it throws, like the logger proxy — better than silently running on the pool and bypassing RLS.

db stays a first-class slot, typed from the construct ​

.database() is retargeted, not deprecated: it takes a construct, records the edge, and designates the primary database.

ts
const orders = new KyselyDatabase<OrdersDB>('orders');

api.post('/orders')
  .database(orders)
  .handle(async ({ db, services }) => {
    await db.selectFrom('orders').selectAll().execute();    // Kysely<OrdersDB>
    await services.orders.selectFrom('orders').execute();   // same object
  });

.database(c) is .dependsOn([c]) plus the designation — no second edge. The schema type flows from the construct to db in the handler, db in the session extractor, and services.orders, replacing the hand-threaded TDatabase/TDatabaseServiceName builder parameters.

The query builder is a construct choice; the provider is not:

ts
import { KyselyDatabase } from '@geekmidas/constructs/database/kysely';
import { KnexDatabase }   from '@geekmidas/constructs/database/knex';

Knex is not at parity. @geekmidas/db has ./kysely, ./rls (Kysely-only — it imports sql and Transaction from kysely), and ./objection/pagination, but no ./knex module. KnexDatabase needs one first (a re-entrant withTransaction, a set_config withRlsContext), so it is a follow-up rather than parity work. Objection then comes along free: it resolves its connection at query time via Model.knex(), so binding models to the proxy makes Order.query() join the request's transaction with no trx argument.

What a Database provisions ​

A database construct does not hand out the cluster's credentials. It declares a logical database, a schema, and two roles, and the app connects as the narrower of them.

ts
const orders = new KyselyDatabase<OrdersDB>('Orders');
//  database  → orders
//  schema    → app             (search_path pinned on the role)
//  roles     → app_owner       (DDL, migrations)
//              app             (DML only, what the app connects as)
//  provides  → ORDERS_URL      (the runtime role)

The schema is app, not the construct's id. It names the role the schema plays inside the database rather than restating what the database is already called, and it reads consistently against its neighbours — app alongside auth and pgboss tells you the layout at a glance, where orders alongside auth and pgboss does not. It also keeps the application off public, whose historically loose default grants are a poor place for anything.

Roles are named after their schema — app and app_owner, auth and auth_owner, pgboss. No stage or app prefixing: the deployment model gives each app and stage its own database instance, so roles, which are cluster-scoped, have no one to collide with.

Where the cluster comes from is the adapter's business — an RDS instance on --target=aws, the postgres container locally. The construct says only that it needs a database called Orders.

Two roles wherever RLS is used. A single role that both owns and runs is exactly the configuration where RLS silently does nothing — ENABLE ROW LEVEL SECURITY does not apply to a table's owner. Splitting owner from runtime makes policies apply by default, with no FORCE ROW LEVEL SECURITY to remember.

The split is conditional, not universal: a schema whose tables carry no policies needs one role. pg-boss is the clear case — it owns its queue tables, has no RLS, and a second role would be ceremony. The app's own schema is where policies live, so that is where the split earns its place.

sql
CREATE ROLE app_owner;                          -- migrations
CREATE ROLE app LOGIN PASSWORD '…';             -- runtime
CREATE SCHEMA app AUTHORIZATION app_owner;
ALTER ROLE app_owner SET search_path TO app;    -- both, see below
ALTER ROLE app       SET search_path TO app;
GRANT USAGE ON SCHEMA app TO app;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;

FOR ROLE orders_owner is the easy part to get wrong: without it the default privileges apply to tables created by whoever ran the statement rather than by the migration role, and new tables are silently unreadable by the app.

The adapter creates two secrets, and the master stays behind. SST already generates master credentials for the instance; provisioning adds one secret per role and composes each URL from the matching one:

credentialcreated byused by
masterSST/RDSthe provisioner only — creating roles and schemas
app passwordthis constructORDERS_URL, handed to functions
app_owner passwordthis constructthe owner URL, handed to the migration runner

So no part of the running system holds credentials that can drop a table, and nothing outside the provisioner holds the master.

Opting out falls back to the master credential in both URLs:

ts
new KyselyDatabase<OrdersDB>('Orders', { roles: false });

Useful against a database you do not own, or to get moving before provisioning exists. It gives up the DDL/DML split and puts master credentials in the function's environment, so it should read as a deliberate downgrade rather than a default. One wrinkle it carries: the master user is not the app role, so search_path has to move onto the connection string (?options=-csearch_path%3Dapp) rather than being pinned on the role.

Rotation is two secrets, each two-step. Changing a stored value is not enough — ALTER ROLE … WITH PASSWORD has to run against the database too, or the next connection fails. Worth noting the two rotate independently: the runtime password can be rotated without touching migrations, which is the one you would want to rotate on a schedule.

It yields two connection strings, and only one reaches your code.

rolegiven to
runtime URLordersfunctions, via provides
owner URLorders_ownerthe migration runner only

That is a security property rather than a convention: a compromised handler cannot DROP TABLE, because its role holds no such grant, and the cluster's master credentials never leave the provisioner.

It is also, today, aspirational. No target creates either role. The local target runs CREATE DATABASE and CREATE SCHEMA and nothing else, and the AWS target runs no DDL at all; both URLs carry the cluster's master credential, and roles?: boolean on the declaration is read by nothing. Until the role DDL lands, the split above describes the design and not the behaviour — which is worth stating in the same breath as the claim, because a security property people believe they have is worse than one they know they lack.

Derived nodes name their parent. A reader and a schema tenant are top-level entries that provision nothing of their own — one is an endpoint on an existing cluster, the other a schema inside an existing database — so each carries of: 'Orders'. Without it the adapter would provision a second database for each.

They stay top-level rather than nesting under the database because dependencies[].target resolves as m[target]. Nesting would force a compound reference ({ target: 'Orders', endpoint: 'reader' }), which is the edge attribute we rejected arriving in another form, and would leave a construct whose id is not a key in the map — invisible to collision detection and to every edge.

What each parent may vend is a small table, and it makes cycles impossible without a check, since readers are terminal:

parentmay vend
databasedatabase-reader, database-schema
database-schemadatabase-reader
database-reader—

So authDb.reader() is legal and useful — the auth role, its search_path, pointed at the replica. There is no writer node, because the database is the writer: orders defaults to it and .reader() is the opt-in, so a replica can never be reached by accident.

A reader adds a third role. app_reader, SELECT-only, created on the primary so it replicates:

sql
CREATE ROLE app_reader LOGIN PASSWORD '…';
GRANT USAGE ON SCHEMA app TO app_reader;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT ON TABLES TO app_reader;

That is scoped security in the application, not just routing — and it is what makes read-only hold regardless of endpoint. The reader resolves to a replica where one exists and falls back to the writer where none does (--target=server, dev, a small stage); with grants doing the enforcing, that fallback is safe rather than a silently writable connection behind a name that says reader. RLS applies to app_reader unchanged, since it owns nothing.

Schema tenants are the same mechanism. orders.schema<AuthDB>('Auth') adds a second schema in the same database with its own role(s) and its own AUTH_URL, so the app's role has no grant on the auth tables at all. pg-boss becomes an instance of this rather than the hardcoded special case it is today — with a single role, which is correct for it.

Schemas resolve through search_path on the role, not through the connection string or client config — so db.selectFrom('orders') finds app.orders with no withSchema() call, and a schema tenant works identically through its own role. It must be set on both roles: migrations run as app_owner, so without it they create tables on the default path while the runtime role looks in app and finds nothing — a migration that reports success followed by "relation does not exist". The opt-out path has no role to pin, so there it moves onto the connection string (?options=-csearch_path%3Dapp), which is a second reason that path is a downgrade.

Roles are created by an in-VPC provisioner, triggered as a Pulumi resource. The alternative — @pulumi/postgresql resources for roles and schemas — is declarative and diffable but requires the Pulumi CLI to reach the database at deploy time, which a private RDS does not allow without a bastion. A provisioner function is already in the VPC:

sst.aws.Postgres          the instance, master secret
  → Secret app / app_owner
  → OrdersProvisioner     in-VPC, linked to all three secrets
  → aws.lambda.Invocation runs it during `pulumi up`
  → OrdersMigrator        invoked next
  → app functions         dependsOn the provisioning invocation

Invoking a Lambda is a control-plane call, so Pulumi can trigger it from anywhere; only the database connection needs to be inside the VPC. The invocation's input is a hash of the desired state — schema name, role names, secret versions — so it re-runs when any of those change and is skipped when they do not.

Two things follow. Rotation stops being a two-step procedure: changing a secret changes the invocation's input, the provisioner re-runs, and its ALTER ROLE … WITH PASSWORD branch syncs the database. And the provisioner is the only thing linked to the master secret — it is the one component that can create or drop, which is the entire point of the split and easy to undo by linking master "just for now" to something else.

The cost is that Pulumi does not know the schema exists: no diff, no destroy, and removing a tenant leaves it behind. That lands in the destructive-change gap already deferred to the manifest-diff work rather than opening a new one.

It declares two functions of its own. A database returns three declarations, not one — the database, a migrator, and a seeder:

Orders (database)
  ├── OrdersMigrator   in-VPC function, owner URL
  └── OrdersSeeder     in-VPC function, owner URL

Same shape as Queue returning the queue plus its worker, and it dissolves the in-VPC problem rather than working around it: the migrator is in the VPC, because it is a function the construct declared. Both get the owner URL; nothing else in the system does.

Deploy becomes provision → invoke migrator → deploy the app's functions. Seeds stay manual (gkm seed) or dev-only — running them automatically is how you overwrite production data.

Locally there is no Lambda, so the same migration code runs in-process. Worth stating explicitly so the two paths cannot drift: a migration that works locally and fails in the VPC is the expensive failure.

Ordering stays in the dependency graph. aws.lambda.Invocation is a real resource, so "invoke the migrator after the provisioner" is an ordinary dependsOn rather than a step in a deploy script — and the app's functions depend on the migration invocation the same way. Locally the same ordering is a sequence in gkm setup, since there is no graph to express it in.

Services, loggers, and builders ​

register forwards the whole ServiceRegisterOptions. Cherry-picking envParser silently strips context and with it the request-scoped logger.

ts
this.service = { serviceName: id, register: (options) => this.connect(options) };

ServiceDiscovery caches by serviceName, so register() runs once per process. What survives that:

capture at register()safe?why
context.getLogger()✅a proxy that re-resolves per call, including through child()
getRequestId() / getRequestStartTime()❌plain values, frozen to the first request

Never clone or spread the logger: its proxy target is {} with no ownKeys trap, so {...logger} yields an empty object silently.

Constructs never accept or default a logger. Topic and Queue currently hold DEFAULT_LOGGER = new ConsoleLogger() at module scope — a process-global logger with no request correlation, which is exactly the staleness this design removes. They read context.getLogger() instead.

.service is a constructor-assigned field, not a getter. Done. The draft found Topic.publisher a getter returning a fresh object literal per access, so serviceEnvCache (keyed by object identity) never hit for topic and queue publishers. publisher is gone: a topic's and a queue's publisher is their service, assigned once in the constructor.

Builders return new instances instead of mutating. Done. Every builder — Topic, Queue, Subscriber, Function, Cron, Endpoint — now clones rather than mutating this, and the reset blocks that compensated are gone.

The last sentence of this item used to read "this matters more once .dependsOn() makes partially-applied builders attractive", and it was right in both directions. Adding .dependsOn() to the mutating builders produced exactly two failures. A field left off a reset block leaked into the next handler, so a function was granted a bucket a previous function had declared — an over-grant in the field a deploy reads to size an IAM policy. And every field that was on the reset block could not survive reuse, so const fn = f.logger(log) applied to one handler and silently reverted to the console logger for the next.

Both are the same defect: a hand-maintained list of fields, edited whenever a field is added and silent when it isn't. Cloning copies whatever the instance has, which removes the list rather than lengthening it.

private, not # — the repo uses private 848 times and # zero times, and # interacts badly with the proxies above.

Shape of a Construct ​

ts
export class ObjectStorage<TName extends string = string>
  implements Construct<TName, StorageClient> {

  readonly service: Service<TName, StorageClient>;
  private readonly config: { url: string };

  constructor(public readonly id: TName, private readonly options: ObjectStorageOptions = {}) {
    this.config = { url: `${environmentCase(id)}_URL` };          // declared ONCE
    this.service = { serviceName: id, register: (o) => this.connect(o) };
  }

  declare(): Declaration[] {
    return [{ kind: 'objects', id: this.id,
              config: Object.values(this.config), ...this.options }];
  }

  private async connect({ envParser, context }: ServiceRegisterOptions) {
    const { url } = envParser
      .create((get) => ({ url: get(this.config.url).string() }))
      .parse();
    return createStorageClient(url, { logger: context.getLogger().child({ construct: this.id }) });
  }
}

declare() reads Object.values(this.config) and connect() reads this.config.url — one object, two callers, so declaring a key you don't read is unrepresentable rather than merely unlikely.

Worked Examples ​

Object storage, end to end ​

ts
// before — three files coupled by the string UPLOADS_NAME
export const storageService = { serviceName: 'storage', async register({ envParser }) { … } };
export const upload = e.post('/upload').services([storageService]).handle(…);
// + sst.config.ts: new Storage(stack, 'uploads'), links, envVars
// + gkm.config.ts: services: { … } for the local MinIO container

// after
export const uploads = new ObjectStorage('Uploads');

export const upload = api.post('/upload')
  .dependsOn([uploads])
  .handle(async ({ body, services }) => services.uploads.put(key, body));
// sst.config.ts: nothing — bucket, link, env, and IAM all derive

Serving files ​

ObjectStorage is the bucket: it writes, it presigns, and its URL is never public. Serving those objects on a domain is a different thing, and where it belongs took three passes to settle.

ts
export const uploads = new FileServer('Uploads', { open: ['brand/**'] });

uploads.getUploadURL(key, 15)         // an S3 presign against the bucket
uploads.url('brand/logo.png')         // open path — no signature
uploads.openUrl(dynamicKey)           // same check, for a key the type cannot see
uploads.signedUrl(key, 15)            // one object, one recipient

One construct, one client, both halves. How a file lands in the bucket is the same whether or not it is served, so splitting the upload half from the serving half would be ceremony rather than safety: the client is a superset of StorageClient, which is worth making literal in the type, since anything taking services.uploads today keeps compiling when the construct behind it grows a serving half.

It vends its bucket, and takes one when you have it. new FileServer('Uploads') declares both nodes; new FileServer('Files', { origin: uploads }) fronts a bucket declared elsewhere, which is also how the two edges come back when you want them — a handler depending on the bucket writes, a handler depending on the server mints URLs.

The id names the bucket, not the surface. This is the one detail that has to be right before anything ships, because getting it backwards is destructive:

new ObjectStorage('Uploads')   → Uploads        { kind: 'objects' }
new FileServer('Uploads')      → Uploads        { kind: 'objects' }                    ← unchanged
                                 UploadsServer  { kind: 'file-server', of: 'Uploads' }  ← added

The migration people will actually perform is serving a bucket they already have. Naming the bucket after the construct means that edit adds a node and a provided key and touches nothing holding data. The other way round — surface takes the id, bucket gets a suffix — the same edit renames the bucket, which replaces it, and the plan shows a destroy that nothing explains.

Which puts the derived form back in the manifest, inverted from where it was first proposed: the construct is standalone, with its own provisioner owning the certificate and the DNS record, while the surface node derives from the bucket by of — which is what makes the reference check work and lets gkm answer "is this bucket served?" from one lookup.

Open paths are a type, not a convention ​

open is checked at the call site, not only at the edge:

ts
const files = new FileServer('Uploads', { open: ['brand/**', 'avatars/**'] });

files.url('brand/logo.png')          // ok
files.url(`avatars/${id}.png`)       // ok — a template literal still matches
files.url('invoices/7.pdf')          // ✗ not assignable — never served unsigned
files.signedUrl('invoices/7.pdf')    // ok — this is what signing is for

The patterns become a key type by the same trick ConstructName uses, with a const type parameter so no call site needs as const:

ts
type Served<P extends string> =
  P extends `${infer Head}**`            ? `${Head}${string}` :
  P extends `${infer Head}*${infer Tail}` ? `${Head}${string}${Tail}` :
  P;

Three limits, stated rather than discovered:

  • * and ** are the same type. A template literal cannot exclude /, so the compile-time check is a prefix and suffix guard while the exact pattern is enforced by the bucket policy and the cache behaviour. The type says plausibly open; the infrastructure says definitely.
  • A fully dynamic key does not type. files.url(someString) is a compile error, which is correct and also the case people will hit — so there is an explicit escape hatch rather than a cast, and it is runtime-checked, because minting an unsigned URL for a private object is a leak rather than a mistake.
  • Runtime checks anyway. A JavaScript caller gets no compiler, which is the same argument that gives canonicalId a runtime guard beside ConstructName, and .dependsOn() a NotAConstruct beside its type.

Uploading is deliberately not restricted: writing to a path that happens to be open is ordinary, and the only asymmetry worth enforcing is that you cannot hand out an unsigned URL for something the server would refuse to serve unsigned.

Why a separate construct, and what it costs ​

Three shapes were on the table. The argument is worth keeping because the obvious answer is not the one that survives.

A — an option on the bucket, new ObjectStorage('Uploads', { cdn: true }), which is what this document originally said.

B — a derived construct, uploads.server({ … }), in the shape orders.reader() and orders.schema() already use.

C — a construct of its own, new FileServer('Uploads', { open: … }).

A: optionB: derivedC: own construct
ids in the manifest12, parent named by of2, related by an edge
"is this bucket public?"read one declarationread one, follow offind whoever points at it
two surfaces over one bucket✗✓✓
one surface over two buckets✗✗✓
an origin that is not a bucket✗✗✓
who owns cert + DNS + cache configthe objects provisionerthe objects provisionerits own
adding serving to a live bucketchanges the bucket nodeadds a nodeadds a node
shares machinery with StaticSite✗✗✓ — same infrastructure

A dies on the first two rows of capability: cdn: true is a boolean on a bucket, so it cannot express open-versus-signed paths, cannot express two surfaces, and grows a second provides key that is only sometimes there — every consumer then branches on its presence.

B survives longer, and the case for it is real: one bucket, one lifecycle, one id, and a second address on the same resource is exactly what reader() and schema() are. What kills it is the relationship. A reader and a schema tenant share the parent's credentials — they are the same database reached with different grants, which is why deriving them from it reads correctly. A file server shares the parent's contents, which is a different thing entirely, and the difference shows up as three concrete failures:

  • A distribution can front several origins; a method on one bucket cannot express that. The moment you want /* from the uploads bucket and /__session from the API — which is how a cookie gets minted on the file host rather than the parent domain — B has nowhere to put it.
  • The provisioner ends up wrong. of makes the parent's component responsible for the child, so the objects provisioner would be issuing ACM certificates and writing Route53 records. That is a domain lifecycle living inside a bucket.
  • It duplicates StaticSite. A site is already a bucket, a distribution, a certificate and a domain. A file server is the same infrastructure with live bucket contents instead of a build output. Under C they share a provisioner; under B one of them is a method and the other is a construct.

So C, with its cost named rather than waved away: the bucket alone no longer tells you whether it is served. Under A you read one declaration; under C you have to find whoever points at it. That is a real regression in auditability and it is answered the same way of is — a reference check at manifest build, so every origin resolves to a declared construct and gkm can print, for any bucket, the surfaces that serve it. Unresolvable origin, build failure; that is the same guarantee assertDerivations already gives derived database nodes.

The second cost is naming. Two constructs invite "which one do I put things in?", which is why the serving construct is not called FileStorage — nothing is stored in it. It serves.

Three axes get conflated here, and separating them is most of the design:

axischoicesdecided by
does reading need a signatureopen / signedthe object, via its path
how a signature travelscookie / URLthe caller, per call — not configuration
which address you holdbucket (write, presign) / server (domain, read)the edge

Private by default; open is an exception list. A bucket where forgetting a flag publishes user uploads is the wrong default. open names the paths served without a signature; everything else requires one. Paths rather than per-object flags because that is what the infrastructure enforces — with origin access locked down, a CDN keys behaviours off path patterns, and a per-object ACL is a thing nobody audits. Listing them in code means exactly one set of paths is unauthenticated and it is reviewable in a diff.

"Public" never means the bucket is world-readable. It means the server serves that path without a signature. The bucket stays private in both cases.

The subdomain is the address, not the boundary. Signed content is served from the same host — and for cookies it must be, since a cookie is scoped to a domain. Two servers over one bucket is a legitimate arrangement (public assets that never carry an auth cookie, and cleaner cache keys), but that is a cache-behaviour decision, not a secrecy one.

Both signature mechanisms are always available. A behaviour with restricted viewer access is configured once, with a trusted key group, and then accepts either a signed URL or signed cookies — the URL wins when a request carries both. So there is nothing for the construct to declare beyond the key material, and which mechanism is used is decided per call by whoever mints it. Note that this is CloudFront signing with a key pair through the distribution, which is a different mechanism from the S3 presign getUploadURL issues with IAM credentials straight at the bucket: same word, different keys, different host.

Cookies are set at the parent domain, Domain=.example.com, exactly as the auth section already requires — so the file server needs to know nothing about the API, and there is one domain rule in the system rather than two. Two assumptions come with it, and they are assumptions rather than accidents: every subdomain under that parent is yours (a parent-scoped cookie can be overwritten by anything under it, so per-tenant subdomains would need revisiting), and open behaviours are configured not to forward cookies, so the browser sending one does not pollute the cache key.

Per-user authorisation is out of scope for path patterns. "Can this user read invoice 7" is not expressible as a prefix; the answer is that the app authorises and redirects to a short-lived signed URL. Saying so is better than implying patterns cover it — the alternative is an edge function doing auth, which is a large surface to sign up for.

Multi-origin is deliberately deferred. A distribution can front several origins, and origin will grow into an ordered origins: [{ path, origin }] the day something needs it — an array rather than a map, because behaviour precedence is most-specific-first and object key order is an invisible place to keep that. It is deferred because it costs the client its vocabulary: with one origin at the root a served path is a bucket key, and getUploadURL(key) and url(key) speak the same language. With several they diverge, and only the people who opt in should pay that.

What landed, and what a signature currently means ​

FileServer exists, kitchen-sink's bucket is one, and endpoints/uploads.ts still calls services.uploads.getUploadURL(...) unchanged — which is the superset claim verified rather than asserted.

Uploads        { kind: 'objects' }                                  ← unchanged
UploadsServer  { kind: 'file-server', of: 'Uploads', open: ['brand/**'] }
UPLOADS_URL        = s3://uploads?region=…&endpoint=http://localhost:20002&…
UPLOADS_SERVER_URL = http://localhost:20002/uploads

Three things came out differently from the sketch, and they are worth naming because two of them are limits rather than choices.

signedCookie is not implemented, and signedUrl is an S3 presign. Both mechanisms remain available in the design and both are CloudFront signing with a key pair through the distribution — which needs key material the construct does not yet declare, and which has no local equivalent at all. What ships is the mechanism that works identically in both places: an S3 presign at the bucket. The docblock says so rather than calling it a CDN URL, because "same word, different keys, different host" is exactly the confusion that makes this worth stating twice.

The escape hatch is a method, not a cast. A fully dynamic key does not type — correct, and also the case people hit — so openUrl(key: string) takes it and runs the same runtime check. url() is the typed door and openUrl() the untyped one; neither is a way past the guard.

A single star is stricter in the client than in the policy. The construct's runtime check stops * at a segment boundary, matching what a CDN behaviour does. An S3 policy resource's * crosses /, so avatars/*.png admits avatars/2024/me.png in the bucket policy and is refused by the client. The client is the stricter of the two, so nothing it refuses was ever relied on the policy to refuse — but a key fetched directly, bypassing the client, can be admitted. Prefer ** where crossing segments is what you meant.

Locally, on MinIO ​

Three of the four map cleanly:

capabilitylocal
the addressMINIO_DOMAIN gives virtual-host addressing — uploads.myapp.test/brand/logo.png is the CDN URL shape, no bucket segment, no proxy
open patternsa MinIO bucket policy with prefix resources — real enforcement, same declaration
signed URLsS3 presigning works against MinIO unchanged
signed cookiesnothing — CloudFront-specific

The addressing constraint is where choosing a separate construct is felt locally: MinIO's virtual-host mode reads the leading label as the bucket name, so it only produces the CDN shape when the server's id and the bucket's id agree — Uploads serving uploads. A FileServer named for what it serves rather than for its origin (files.myapp.test over bucket uploads) does not resolve, and a server fronting two buckets cannot resolve by construction. So under C the local target either falls back to MinIO's path-style URL — correct, but not the deployed shape — or grows a small proxy in front of MinIO that maps host and path patterns onto buckets the way a distribution does.

What landed is the first branch: UPLOADS_SERVER_URL is MinIO's path-style address for the origin bucket, and the open patterns become a real MinIO bucket policy — anonymous s3:GetObject on those prefixes and nothing else, applied by the reconciler and idempotent, so a path that is open locally is open for the same reason it is open deployed.

That proxy is the honest answer for the shape, and it is additive: it changes no construct API, and it is the only component that could also verify a signature locally. It is not something an AWS emulator gives you for free — CloudFront emulation in LocalStack and in floci is control plane only (distributions, origins, cache behaviours, invalidations), which provisions a distribution that never serves a byte. What the local target needs is the data plane, which is the half nobody emulates.

The cookie gap is why the client should expose one read(key) that resolves however the stage can, rather than app code branching on a mechanism that only half exists locally. If cookies are the default path in application code, the first time anyone learns whether the domain, Secure, and SameSite are right is on deploy.

A database, end to end ​

ts
// before — a service, the schema type hand-threaded through the builder,
// sst.config.ts, and `services: ['postgres']` in gkm.config.ts
export const dbService = { serviceName: 'db', async register({ envParser }) { … } };
export const listOrders = e.get('/orders').database(dbService).handle(…);

// after
export const orders = new KyselyDatabase<OrdersDB, 'Orders'>('Orders', {
  plugins: [new CamelCasePlugin()],
});

export const listOrders = api.get('/orders')
  .database(orders)
  .handle(async ({ db }) => db.selectFrom('orders').selectAll().execute());

Both type arguments or neither. TypeScript has no partial type-argument inference, so new KyselyDatabase<OrdersDB>('Orders') leaves the name parameter at its default and the service key widens from orders to string. db still types either way, because .database() infers from the service.

Options are Omit<KyselyConfig, 'dialect'> plus what the construct declares. The construct owns dialect — it is built from the one URL the adapter supplied — and everything else Kysely takes passes through, so plugins and log need no per-field plumbing here.

Derived constructs are top-level entries that provision nothing of their own:

ts
export const ordersReader = orders.reader();                 // OrdersReader, SELECT-only role
export const auth = orders.schema<AuthDB, 'Auth'>('Auth');   // second schema, own role, own URL

which is four manifest entries from three lines:

ts
Orders       { kind: 'database',        schema: 'app',  provides: ['ORDERS_URL'] }
OrdersReader { kind: 'database-reader',  of: 'Orders',  provides: ['ORDERS_READER_URL'] }
Auth         { kind: 'database-schema',  of: 'Orders',  schema: 'auth', provides: ['AUTH_URL'] }
AuthReader   { kind: 'database-reader',  of: 'Auth',    provides: ['AUTH_READER_URL'] }

app's role holds no grant on auth's tables, so a compromised orders handler cannot read sessions — and none of it is stated twice.

The same construct is what a test connects through, so configuration cannot diverge between them:

ts
const isolatedTest = isolator.wrapVitestWithTransaction({ connection: orders });

isolatedTest('lists orders', async ({ trx }) => {
  await adaptor.request({ database: trx, … });
});

A queue and its producer ​

ts
export const processOrder = worker
  .queue('ProcessOrder')
  .message(z.object({ orderId: z.string() }))
  .batchSize(10)
  .dependsOn([ordersDb])
  .handle(async ({ messages, services }) => { … });

export const createOrder = api.post('/orders')
  .dependsOn([ordersDb, processOrder])
  .handle(async ({ body, services }) => {
    const order = await services.ordersDb.insertInto('orders').values(body)
      .returningAll().executeTakeFirstOrThrow();
    await services.processOrder.publish([
      { type: 'ProcessOrder', payload: { orderId: order.id } },
    ]);
    return order;
  });

Two declarations from one construct — the queue and its worker — and the producer consumes only the publisher. The adapter derives asymmetric permissions from the same resource: sqs:SendMessage for createOrder, ReceiveMessage/DeleteMessage plus the event-source mapping for the worker.

Not a callable, deliberately: a Function's client is callable because it's request/response and returns the output type; sending to a queue is fire-and-forget. (The draft proposed send(message); what shipped is the EventPublisher every broker already implements, publish([{ type, payload }]), with type the queue's name as written.)

Topic fan-out ​

ts
export const userEvents = new Topic('UserEvents', {
  events: {
    'user.created': z.object({ id: z.string(), email: z.email() }),
    'user.deleted': z.object({ id: z.string() }),
  },
});

export const sendWelcome = worker.topic(userEvents)
  .subscribe(['user.created']).dependsOn([emailer]).handle(async ({ events }) => …);

export const provisionWorkspace = worker.topic(userEvents)
  .subscribe(['user.created', 'user.deleted']).dependsOn([ordersDb]).handle(…);

// A producer names the topic per event, after its handler succeeds:
export const signUp = api.post('/users')
  .event(userEvents, { type: 'user.created', payload: (u) => ({ id: u.id, email: u.email }) })
  .handle(…);

Fan-out is "more than one declaration names this topic". Nothing about Topic changes when you add the fifth subscriber. The handler's events are the topic's event union narrowed to the selected keys, so subscribing to an undeclared event is a compile error.

The adapter gives each subscriber its own SQS queue and DLQ before the Lambda — one subscriber failing and retrying must not stall the others.

An auth server ​

Worth working through because it exercises most of the model at once: an auth server is three things simultaneously — a set of endpoints, a consumer of a database, and a producer of an authorizer.

ts
const ordersDb = new KyselyDatabase<OrdersDB>('orders');
const authDb   = ordersDb.schema<AuthDB>('auth');        // role + schema tenant

export const auth = new BetterAuth('auth', {
  database: authDb,
  basePath: '/api/auth',
  providers: ['github', 'google'],      // structural; client secrets at deploy
});

export const api = new RestApi('api', { authorizers: [auth], default: auth });

authorizers: [auth] is the second form of authorizer: a bare name is resolved externally, while a construct-provided one carries its implementation, its database dependency, its secrets, and its session typing together.

.service is a client of the auth app — services.auth.api.getSession({ headers }), answered over HTTP at AUTH_URL — matching the rule that consuming a construct gives you the client for the address it owns. (It was once the server instance, which built Better Auth — secret, tenant and mailer — inside every app that consumed it.) The server runs only in the auth app's own entry, auth.server(). The browser client is the out-of-app consumer, exactly parallel to api.service versus the generated OpenAPI client.

Schema tenants. ordersDb.schema<AuthDB>('auth') generalizes a pattern already in the repo: the generated docker init script gives pg-boss a dedicated role owning its own schema, with search_path set on the user and default privileges applied. That's stronger than a schema qualifier — the app's role has no grant on auth, so it cannot read those tables — and the separate type parameter mirrors the permission boundary, since OrdersDB must not contain the auth tables.

A schema tenant therefore declares a role, a schema, a generated password secret, provisioning DDL, and a migration set. Three consequences:

  • Role names are cluster-scoped, schemas are database-scoped. Two apps or stages sharing an instance collide on a bare auth role, so the role needs prefixing while the schema keeps its plain name. The manifest's collision check won't catch this — it is per app, and this collides across them.
  • Rotation is two-step. Changing the stored secret isn't enough; ALTER USER … WITH PASSWORD has to run against the database too.
  • Migrations must run inside the VPC, before any dependent function, and in order: provision the role and schema, then apply the tenant's own schema. This is the one genuinely new capability the design needs — and the same gap that leaves pg-boss's role uncreated on a deployed database today.

Trusted origins derive from the graph. Better Auth's CSRF check applies to every caller, not just browsers — an API Lambda calling the auth Lambda is rejected unless its origin is trusted. So the list unions two sources, both declared by the consumer:

sourcegives
manifest edgesany construct depending on auth → the API_URL of the surface it lives on
workspace usesweb apps → their per-stage domains, plus localhost in dev

Frontends must be listed explicitly, since they aren't constructs and no edge exists to derive from — declared on the app (uses: ['api', 'auth']), keeping the one direction the rest of the design uses: consumers declare, nothing enumerates its own consumers. The same list feeds RestApi CORS, since it is the same question asked twice.

Cookies require one registrable domain. console.example.com → api.example.com is cross-origin but same-site, so a cookie with Domain=.example.com and SameSite=Lax is sent on those requests. An app on a different registrable domain cannot share the cookie at all and must use bearer tokens instead.

Supported today: apps under one base domain. Anything else uses bearer tokens. This is detectable at build — once origins are resolved, each is compared against the stage's root domain, and an off-domain app fails with a specific message rather than a mysterious 401 in the browser.

Two knock-ons: CORS cannot be *, because credentialed requests require an explicit origin, which the derived list supplies; and mounting auth on the API surface is the better default, since the cookie is then same-origin with the calls that use it.

Consolidating providers: abstract the authorizer, not the server ​

A self-hosted auth server and a hosted identity provider are not the same thing wearing different clothes. One owns a database, mounts endpoints on your domain, and issues your session; the other owns nothing of yours and hands you a token to verify against a JWKS. A facade over both ends up leaky or anemic — and the anemic version is the one that abstracts user management, orgs, and admin APIs into an interface too thin to use.

What they genuinely share is one thing, and it is the thing every endpoint consumes:

ts
verify(request) → Session | null

So the authorizer is the consolidation point, and the provider construct is not consolidated at all:

ts
export const auth = new BetterAuth('Auth', { database: authDb, … });  // owns a DB, a surface, a secret
export const auth = new OidcAuth('Auth', { issuer: 'https://…/' });   // owns a client secret, nothing else

export const api = new RestApi('Api', { authorizers: [auth], default: auth });

services.auth stays the provider's own client — no lowest common denominator — while session in a handler is uniform because it arrived through the authorizer. The hosted half is largely written already: @geekmidas/auth has oidc.ts and jwt.ts on jose with hono and lambda adaptors, so OidcAuth wraps existing code rather than reimplementing verification.

The auth server is its own surface ​

Mounting auth on the API surface was argued from cookie same-origin. Once the cookie is set at the parent domain — which the file server needs anyway — auth.example.com reaches the API just as well, so mounting becomes a deploy-shape question rather than a correctness one: its own surface means its own scaling, its own cold starts, its own blast radius, and an AUTH_URL that the browser client and the trusted-origins derivation both want. The construct owns a surface; whether the adapter gives it a distribution of its own or a path behaviour on the API's is the adapter's call, exactly as it is for CDN-enabled buckets.

The client is generated, and the server dictates capabilities ​

A site that depends on auth should get its client the way it gets an API client: derived, not hand-written. The naive form of that — generating the provider's own client with its plugin twins — means maintaining a name registry that lags the provider's releases, and better-auth's plugin options hold server-only closures that cannot be serialised anyway.

The form that works inverts it: the generated client's API is ours, and the provider is an implementation detail behind it.

ts
auth.signIn.magicLink({ email })   // exists because the server declared it
auth.signIn.oauth('github')
auth.session()
auth.signOut()

The server dictates capabilities; both sides no longer declare them. That is the same rule as provides/requires — one declaration, both sides derive — and it removes a class of bug outright: a client offering a sign-in method the server never enabled. It is also the same shape as ProvidesByKind, a neutral contract with provider-specific codecs behind it; s3:// and gs:// parse privately and never reach the neutral layer, and neither should a plugin name.

Where capabilities overlap, swapping providers changes no application code. Where they do not, it is a compile error rather than a runtime 404, which is the argument that makes the facade worth having.

Two limits, stated rather than discovered:

  • Generated at build, verified at runtime in dev. Build-time generation is what makes the capability set a type; a discovery document fetched at runtime would keep a deployed frontend honest across a backend change without a rebuild but gives up the compile-time surface. Generate types, and warn on mismatch in dev.
  • The facade covers sign-in, session, and sign-out. Everything else reaches the provider through an escape hatch (auth.raw), which is also a one-line signal in review that neutral ground has been left.

Known deviation. The kitchen-sink auth construct currently takes better-auth plugins directly — the app imports magicLink from better-auth/plugins and passes it in, which is the provider leaking into application code that this section exists to prevent. It should be a capability the construct translates (capabilities: { magicLink: { send } }), with the send callback still reaching the mail construct the way it does now.

A site ​

A frontend is a construct like any other, which removes the last thing that needed a parallel mechanism: the uses list invented for trusted origins becomes an ordinary edge.

ts
export const console = new StaticSite('Console', { path: 'apps/console' })
  .dependsOn([api, auth]);

Four things derive from that edge that are hand-maintained today:

derivedtoday
VITE_API_URL, VITE_AUTH_URL at builda .env per stage
api's CORS originshand-listed
auth's trusted originshand-listed
which generated client lands in which appa manual alias

The first three landed together — see below. The fourth waits on client generation.

It owns an address, so it has a .service — useful in the other direction, since a transactional email needs the console's URL to build links, which is otherwise another hand-maintained variable.

What landed, and the three decisions it forced ​

RestApi and StaticSite exist, and the four hand-maintained things in the table above are now one edge each. The measurement, on kitchen-sink:

Auth  <- [Api]                          # from api.dependsOn([auth])
AUTH_TRUSTED_ORIGINS = http://localhost:3000
API_TRUSTED_ORIGINS  =                  # correct: nothing declares an edge to it yet

That empty value is the point rather than a gap. The list it replaced was two hardcoded localhost ports in a server hook, one of which nothing was serving, and which no deployed stage could have used at all.

A surface's endpoints are found, not declared. RestApi names a routes glob and declares endpoints: []; the build fills them. The construct cannot import route modules to answer "what paths exist" without evaluating the whole runtime graph, so the split is the same one drawn everywhere else — what is structural is declared, what has to be found is found once, by the thing already walking the filesystem. An auth server enumerates instead, because its one wildcard route is structural.

Edges live in two places on a surface, and readers see one. A route's .dependsOn() is about injecting a client into a handler; a surface's .dependsOn() is about calling another surface, which every route on it does. Both are real, so RestApiDeclaration carries dependencies beside its endpoints, and dependenciesOf() unions them — nothing reading the graph has to know which of the two a given edge came from.

The cookie domain is derived, and the Public Suffix List is where it stops.cookieDomain() takes the surface's address and its callers' and returns their shared parent, .example.com. It returns nothing for one host — locally everything is localhost on a different port, cookies ignore the port, and .localhost is not a domain a browser accepts — and nothing for unrelated hosts, where the longest common suffix would be a value that silently fails to set. The case it gets wrong is a.vercel.app and b.vercel.app, which share a registrable suffix rather than a registrable domain; resolving that needs the PSL, which is a downloaded, expiring dataset, so the rule is two labels minimum and the value stays overridable.

Injection: one derivation, per-framework delivery ​

The values a site needs are resolved from its edges like any other construct. What varies is how each framework wants them, so the canonical name comes from the construct (API_URL) and the variant's adapter serialises it:

variantclient-sidewhen
site/staticVITE_API_URL, or config.json / inlined window.__GKM__build or deploy
site/nextNEXT_PUBLIC_API_URLbuild
site/tanstackVITE_API_URLbuild
ExpoEXPO_PUBLIC_API_URLbuild

Same pattern as everywhere else: one neutral name, adapter-specific serialisation.

Server-side resolution is not special. An SSR site's server half reads links and Credentials through envkit exactly as a function does, so a Next server component querying the database directly is ordinary. A static site simply has no server half — a property of that variant's runtime, not a different rule.

So there are two injection rules, applied uniformly:

  • public-safe provides → client-side, prefixed per framework
  • all provides → server-side through envkit, wherever a server exists

The public marking is what keeps credentials out of the bundle. VITE_, NEXT_PUBLIC_, and EXPO_PUBLIC_ all mean shipped to the browser, so ProvidesByKind marks which values may be prefixed — a rest-api URL and a CDN-enabled objects URL may; database, cache, queue, topic, and secret may not. ORDERS_URL contains credentials and never crosses into a bundle.

That marking drives exactly one thing — prefixing. It is not a restriction on what a site may depend on, because the server half can legitimately use anything. An edge whose value nothing consumes (a database on a static site) is covered by the general "declared but unusable" check rather than a rule of its own.

When values must be available depends on the framework, not on the design. A variant that inlines at build (NEXT_PUBLIC_*) needs its inputs before building, so a stable custom domain earns its place there — the stage's domains map is known ahead of provisioning, where a cloud-assigned hostname is not. A variant reading deploy-injected config or server-side env does not care.

Naming follows the existing rule — the framework changes the code you write, so it is a second segment: constructs/site/static, /site/tanstack, /site/next, each mapping to the SST component that already exists.

Out of scope: Expo and mobile, which build to app stores rather than to infrastructure — there is no URL and nothing to provision. And SSR variants should follow StaticSite rather than ship with it: static hosting is stable, SSR deploy shapes are not, and each one is maintenance.

The Manifest ​

A map keyed by id. Anything that can be depended on is top level; anything triggered by a surface nests inside it.

ts
{
  ordersDb:  { kind: 'database', provides: ['ORDERS_DB_URL', 'ORDERS_DB_READER_URL'] },
  uploads:   { kind: 'objects',  provides: ['UPLOADS_URL'] },
  sendEmail: { kind: 'function', provides: ['SEND_EMAIL_URL'], requires: [],
               handler: '…', dependencies: [] },

  api: { kind: 'rest-api', provides: ['API_URL'],
         authorizers: ['jwt', 'iam'],
         endpoints: [
           { id: 'createOrder', handler: '…', method: 'POST', path: '/orders',
             authorizer: 'jwt', requires: ['ORDERS_DB_URL'],
             dependencies: [{ target: 'ordersDb', kind: 'database' }] },
         ]},

  userEvents: { kind: 'topic', provides: ['USER_EVENTS_URL'],
                events: ['user.created', 'user.deleted'],
                subscribers: [
                  { id: 'sendWelcome', handler: '…', events: ['user.created'],
                    requires: ['SEND_EMAIL_URL'],
                    dependencies: [{ target: 'sendEmail', kind: 'function' }] },
                ]},

  processOrder: { kind: 'queue', provides: ['PROCESS_ORDER_URL'],
                  worker: { id: 'processOrderWorker', handler: '…', requires: […], dependencies: […] } },
}

Position is the trigger, so there is no trigger field — createOrder being built from api is the statement that the API triggers it. One less thing to state, one less reference to validate.

The rule for what nests follows from what's consumable: nothing ever depends on an endpoint, subscriber, or worker. An endpoint's address is just its API's URL plus a path, so RestApi.service is the typed caller and the endpoint has no service of its own. Since they're never edge targets, they don't need top-level identity — while resources, surfaces, and standalone functions do, because dependencies[].target resolves as m[target].

Each top-level entry therefore arrives at the adapter with everything needed to construct it — no filtering, no index, no lookup.

Every construct lands in the manifest, and the manifest is what resolves dependencies — not references captured between construct objects. Discovery imports every module before anything resolves, so assembly has the complete set and import order can't affect the result.

provides and requires are directional and both explicit. provides is what a construct resolves onto its dependents; requires is what it needs. A function is the one kind with both: it provides its invoke URL and requires its own edges' keys.

kindprovidesrequires
objects, database, cache, topic, queueits URL(s)—
rest-apiAPI_URL + authorizer keys—
functionSEND_EMAIL_URLits edges' keys
endpoint, subscriber, worker, cron—its edges' keys

Which makes the central claim an assertion rather than a convention: requires must equal the union of provides across dependencies, plus whatever fromService lifts sniffed.

Defaults are resolved at build. An endpoint that inherited its API's authorizer records the concrete value, so no adapter re-implements inheritance. Same principle as env: the manifest holds resolved facts, not rules.

Back-compat. The existing per-kind fields (routes, crons, …) are emitted as derived projections for the deprecation window, so @geekmidas/cloud/sst consumers compile unchanged.

Validation at manifest build ​

  • Id collisions on environmentCase(id) — subsumes casing and separator variants, and is the form that actually collides in the env. Across all constructs regardless of kind, plus lifted services: a Topic and a Database both named orders conflict on ORDERS_URL and services.orders. Scope is per app; cross-app is handled by the adapter's prefixedName.
  • Reference integrity — every dependencies[].target resolves to a top-level entry in the map.
  • Derivation — every of resolves, and the (parent.kind, child.kind) pair is one the parent may vend.
  • requires ⊆ ⋃ provides across a construct's dependencies — the declared contract, enforced. This is what would have caught function omitting its own provides: a consumer would require a key nothing supplies.
  • Route uniqueness per API on ${method} ${path} across the endpoints built from it.
  • Authorizer names — every endpoint's authorizer exists on its API.
  • Subscribed events — every subscriber's events are declared by its topic.
  • Cycles over function→function edges.
  • Dead declarations — a topic no subscriber consumes, an API with no endpoints.

The same check runs in gkm dev, since discovery already re-runs on change. The error message is the value: both source locations, the canonical form they collided on, and which surfaces conflict.

Discovery: one constructs glob ​

ts
// before                        // after
{ routes: 'src/api/**/*.ts',     { constructs: 'src/**/*.ts' }
  crons: 'src/crons/**/*.ts', … }

A glob per kind is the same specialness the model removes — and resources have no kind to be listed under, so a declared ObjectStorage would never be discovered. Generator already imports each module and inspects every export, so the per-kind isConstruct checks collapse into one structural test. PartitionedRoutes retires with it: surfaces are the deploy slices, so Routes becomes string | string[].

The dev watcher must be fixed in the same phase, because a single glob makes discovery more central, not less. Today gkm dev cannot discover anything new:

ts
watcher.on('change', …)   // the only handler — no 'add', no 'unlink'

Two compounding gaps. There is no add/unlink handler, so a new construct file never triggers a rebuild and a deleted one leaves stale routes. And dirsToWatch is computed once at startup from directories that already contain matching files, so a brand-new directory isn't watched at all — meaning even adding the handler is insufficient without watching the glob's roots.

With constructs: 'src/**/*.ts' the fix is also simpler: watch the root, handle add/change/unlink, and re-run discovery plus the validation pass on each event. That gives new constructs, deleted constructs, and collision/reference errors the moment they're introduced rather than at deploy.

gkm dev orchestrates; it does not run everything ​

gkm dev is for the backend. Sites run their own dev servers — vite dev, next dev — because reimplementing them means tracking frameworks that move faster than this one.

At the workspace root it does five things, all derived from the manifest:

  1. ensure the local containers declared by the resources are up
  2. provision locally — schemas, roles, buckets — the same step the adapter does remotely
  3. run migrations through the declared migrator, in-process
  4. resolve the manifest, assign an address per surface, compose credentials and env
  5. hand off to turbo run dev

Step 4 is where the value is. gkm knows where api and webhooks are listening, so a site's VITE_API_URL is derived rather than written into a .env.local that drifts. Same derivation as production with different values, which is what makes local behaviour predict deployed behaviour.

Each package then runs its own dev task, and each surface watches its own files — which is where the add/unlink fix belongs, rather than in one root watcher.

Migrations run the same code locally that the migrator runs in the VPC. Worth stating explicitly so the two cannot drift: a migration that works locally and fails in the VPC is the expensive failure.

One constraint worth knowing: a persistent turbo task cannot be depended on by another, so a site's dev cannot dependsOn an API's. Turbo's with co-schedules them, but there is no guarantee the API is listening before the site builds. Irrelevant for a SPA, which fetches at runtime; it matters for SSR frameworks that call the API during build.

Local addresses ​

By default a surface is localhost:3000 — zero setup, works offline. That is also where a class of bugs hides until deploy, because localhost is not a normal origin:

localhostreal hostnames
Domain=.example.com sharingcannot be exercisedreal
SameSite=None; Securelocalhost is exempt from Securereal
CORSsame host, so no preflightreal cross-origin

So the local domain is yours to choose, and gkm verifies rather than prescribes:

ts
// gkm.config.ts
dev: { domain: 'myapp.test' }     // → api.myapp.test, console.myapp.test

gkm setup resolves one of the names. If it already points at loopback there is nothing to do; if it does not, it offers to write a delimited block to the hosts file — a separate privileged command, never something gkm dev escalates to on every run. The tradeoff then belongs to your choice rather than to the framework:

you choosehosts editworks offline
myapp.testonce✅
myapp.localtest.me, sslip.ionone — already resolves✗ needs DNS
a domain you own, wildcard → 127.0.0.1none✗ needs DNS

HTTPS needs a locally trusted certificate — mkcert "*.{domain}", so adding a surface never requires reissuing one. Keep that separate from resolution rather than using a service that publishes its wildcard private key: fine for local dev in principle, awkward to defend, and it stops working if they disappear.

The label is per-construct; the base domain is not. A construct's subdomain defaults to the kebab-case of its id and can be overridden — including to false, which claims the apex, where a site usually belongs. false reads as "no subdomain" where an empty string blurs into "unset":

ts
new RestApi('Api')                             // → api.{domain}
new StaticSite('Console', { subdomain: false })  // → {domain}  (apex)
new StaticSite('Admin', { subdomain: 'admin' })
local                          deployed
myapp.test                     example.com            Console
api.myapp.test                 api.example.com        Api
webhooks.myapp.test            webhooks.example.com   Webhooks

That keeps the D23 split intact — the label is structural, since the app's URL layout and the links it generates depend on it, while the base domain varies by stage and resolves at deploy.

What must not vary is the layout between local and deployed. If local is api-local.myapp.test while production is api.example.com, the cookie and CORS parity this buys starts leaking again — and cookie sharing in particular depends on it: a cookie set at the apex with Domain=.example.com reaches api.example.com only because they share a registrable domain, which is exactly the arrangement the local layout has to reproduce.

One deployment wrinkle the apex carries: DNS forbids a CNAME there, so an apex site needs an ALIAS/A record rather than the CNAME a subdomain uses. Route53 handles it, but it is a different record type and worth the adapter knowing.

Two implementation notes: containers do not inherit any of this, since a container resolving the name gets its own loopback — they need extra_hosts generated into the compose file. And the parity is only real if the addresses are used; if gkm dev still prints localhost:3000 alongside them, people use the shorter one and the CORS path stays untested.

Target Adapters ​

Each kind maps to a component in @geekmidas/cloud/sst, which already wraps the SST resource and implements GkmLinkable:

kind@geekmidas/cloud/sstSSTprovisioned
objectsObjectStoragesst.aws.Bucket✓
file-serverFileServersst.aws.Router✓
siteStaticSitesst.aws.StaticSite✓
queueQueuesst.aws.Queue✓
topicTopicsst.aws.SnsTopic✓
secretSecretsst.Secret✓
database—sst.aws.Postgressee Open Question 4
database-reader——see Open Question 4
database-schema—(no AWS resource)after database
cache—(needs a provider)undecided
email—sst.aws.Emailundecided
rest-apiApisst.aws.ApiGatewayV2after the endpoint merge

The six that landed are the six with no open question attached. The remaining six are blocked on decisions rather than on work, and the decisions are worth stating so nobody mistakes them for a to-do list:

  • database is Open Question 4 — whether the adapter creates a replica or points at one, and what --target=server does where there is none. Also unchosen at the time of writing: RDS instance versus Aurora Serverless v2, which is not a detail, because it is the difference between a fixed monthly floor and a scale-to-zero bill. database-schema provisions no AWS resource at all — it is DDL inside the parent — so it follows whatever database becomes.

    Both are now settled — see constructs-outstanding.md §1.1. Nobody creates a replica: a reader resolves to the writer's address, safe because read-only is enforced by the reader role's grants. And it is an RDS instance, not a cluster — the scale-to-zero argument lost to a plainer one about moving parts and predictable pricing.

  • cache has no AWS answer that keeps the client identical. The local target speaks Upstash's HTTP protocol through a proxy in front of Redis precisely so dev and prod run the same client; ElastiCache does not speak it, so provisioning one means a new provider dependency and its credentials.

  • email is SES, and the work is not the identity — it is deriving SMTP credentials from an IAM user, since the declaration promises an smtp:// URL and SES's SMTP password is a signed derivation of a secret access key.

  • rest-api waits on the endpoint merge: the surface node declares endpoints: [] for an app's own API, and routes still reach the deploy target through the separate RouteInfo[] pipeline.

A seventh thing the six forced, worth recording because it constrains the rest: fromManifest provisions in provisionOrder, which orders of and notdependencies. Sites are provisioned last as a result, which is sound only while a site is a pure consumer of addresses. The first kind that needs another construct's value at construction time turns that second pass into a real topological sort — and a cycle check with it.

Linkables declare what they provide ​

Today the direction is backwards: @geekmidas/envkit/sst holds a resolver table keyed by SST type (Bucket → <NAME>_NAME), and LinkedEnvironment runs it in reverse — getProvidersForEnvVars(names) guesses which link supplies a sniffed variable. With provides declared on the construct, the component states it:

The contract between the app construct and the infra component is an interface — the shape of what's provided. Not shared implementation: a shared codec would have to contain bucket and region, which are S3 words, and smuggling those into the neutral layer is the same mistake as queues meaning SQS. An interface stays neutral because it describes only the number and role of the values, never their provider syntax.

ts
// @geekmidas/manifest — type-only, no cloud vocabulary
export interface ProvidesByKind {
  objects:            { url: string; cdnUrl?: string };
  database:           { url: string };
  'database-reader':  { url: string };
  'database-schema':  { url: string };
  cache:      { url: string };
  queue:      { url: string };
  topic:      { url: string };
  'rest-api': { url: string };
}
export type Provides<K extends keyof ProvidesByKind> = ProvidesByKind[K];

/** Which values may be shipped to a browser. Drives client-side prefixing only. */
export const PUBLIC: Record<keyof ProvidesByKind, readonly string[]> = {
  objects:    ['cdnUrl'],   // the bucket URL presigns; the CDN URL is public
  'rest-api': ['url'],
  database: [], 'database-reader': [], 'database-schema': [],
  cache: [], queue: [], topic: [],
};

A read replica is its own construct (orders.reader()) providing its own { url }, so database needs no second key.

ts
// app — declares the names
class ObjectStorage implements Construct<'uploads', StorageClient> {
  declare() {
    return [{ kind: 'objects', id: this.id, provides: keysOf<Provides<'objects'>>(this.id) }];
    //                                                → ['UPLOADS_URL']
  }
}

// infra — supplies the values for the same keys
class ObjectStorage extends sst.aws.Bucket implements Supplies<'objects'> {
  provides(): Record<keyof Provides<'objects'>, $util.Input<string>> {
    return { url: $interpolate`s3://${this.name}?region=${this.region}` };
  }
}

Adding a key to a kind then breaks whichever side hasn't implemented it, in both packages, at build. And the env-name mapping falls out of the same interface rather than being a second convention: environmentCase(${id}_${key}) gives url → UPLOADS_URL and cdnUrl → UPLOADS_CDN_URL.

How the infra composes the string and how the client parses it stay private to each provider pair — s3:// and gs:// never enter the contract.

Composition happens at infra time, from what the base linkable supplies — which is the mechanism already in @geekmidas/envkit:

ts
// SstEnvironmentBuilder.ts — Postgres supplies { database, host, password, port, username }
const postgresResolver = (key: string, value: PostgresValue) => ({
  [`${key}Url`]: `postgresql://${encodeURIComponent(value.username)}:…@${value.host}:${value.port}/${value.database}`,
  …
});

The linked resource exposes its properties; the resolver composes the derived value from them. So a construct's provides() is the same operation, moved onto the component that owns the resource — which is what lets Cache (Upstash, not an SST resource) and any external() resource participate without an entry in a central table keyed by SST type.

You never hand-list env vars either way: linking a Database supplies its config, and Credentials plays the same role in dev.

Values come from the resource itself — this.name, not a recomputed physical name, because the name may be supplied through props or generated by SST and only the resource knows which. Same for region: a bucket can live in a different region than the function reading it, and AWS_REGION in a Lambda is the function's region.

So one edge yields two things: link grants the IAM and carries the properties; provides() composes them into the key the construct declared.

The interface is the contract; the assertion is the enforcement. The app↔infra provides contract is the one place a runtime check earns its keep, because it is the only guarantee that spans two packages, two build phases, and two authors — and a JavaScript consumer gets no compiler help with it at all. So the declared/composed pair is also checked at synth:

ts
assertProvides(declaration.provides, Object.keys(component.provides()));

This is deliberately narrow. Guarantees that live inside a single package — .dependsOn() rejecting a non-construct, a handler's inferred service types — are left to the compiler; duplicating them at runtime is cost without a boundary to defend.

Most of this exists. Api.fromManifest, Function.fromManifest, and Cron.fromManifest already build SST resources from manifest entries; GkmLinkable already carries _id/_type; ResourceType in envkit already maps an SST type to the resolver producing a linked resource's env vars. The chain is closed at every step but the first:

ObjectStorage('uploads')   app code declares
  → kind: 'objects'        manifest (neutral)
  → sst.aws.Bucket         adapter picks the component
  → _type: sst:aws:Bucket  linkable, _id = 'uploads'
  → UPLOADS_URL            adapter writes it from the bucket's outputs
  → connect(env) → client  the construct's own connect(), same key

Which is visible in today's API — functions come from the manifest, resources don't:

ts
Api.fromManifest(stack, 'Api', manifest.routes, { links: [db] });
//                             ^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^
//                             derived           hand-written

After: Stack.fromManifest(stack, manifest) provisions every kind, and each route's links, env, and IAM derive from its own dependencies.

Stacks are a deployment boundary, not a grouping ​

Stacks do not disappear, but they stop meaning "a logical grouping of hand-written constructs" — the manifest supplies that. What remains is the operational use: a state and blast-radius boundary, so that deploying a function cannot touch the database.

They are a different axis from surfaces. A surface is a runtime grouping (which gateway serves what); a stack is a deployment one. Stack also keeps its existing jobs — name prefixing through logicalPrefixedName, and delegating stage, region, and domain from App.

Default is one stack per application. Splitting stateful from stateless is an instruction to the adapter, not something the application declares — otherwise it is partitioning again under a new name.

Filtering: each function gets its dependencies and nothing more ​

A function is linked to exactly the constructs it declared, never the app's full set. This is already the design intent of LinkedEnvironment — documented as "least-privilege linking" — and Api already applies it per route via resolveLink(route.environment).

What changes is what the filter is computed from. Today it runs backwards: sniff the route's services → env var names → ask each link's resolver which names it provides → keep the intersection. With edges it's a direct lookup:

ts
const link = route.dependencies.map((d) => provisioned[d.target]);

Three things improve. The candidate pool stops being hand-maintained. Under- reporting stops being silent — because the filter keys off sniffed names, the catch → [] path doesn't just lose env vars, it makes resolveLink return fewer links, so the route deploys without the resource and fails at runtime with a permissions error. And EnvValidationError guards a narrower gap: it exists because required vars and available links are independently maintained, and one edge collapses that class of error.

The Local Target ​

Local is a target adapter, not a simulation of one. It reads the same declarations fromManifest reads and produces containers instead of cloud resources — same edges, same provides() contract, same assertion that what a component supplies matches what the app declared.

This matters more than it sounds. The alternative — and what exists today — is a hand-written services: ['postgres', 'minio'] list in the workspace config that you keep in step with your code by hand. It is the same triplication the whole design exists to remove, in the one place you touch every day.

Containers are derived, config only overrides ​

gkm docker keeps its templates and its image pinning; what changes is where the list comes from:

todayderived
services: ['postgres','minio'], hand-maintaineda database implies Postgres, objects implies MinIO, email implies Mailpit
fixed default ports, env-overridableallocated per project, URL composed from what was allocated
image and version pinningunchanged — still explicit config
containers no construct implies (redis)unchanged — still explicit config

The same split as ComponentOverrides: neutral facts come from the declaration, provider detail from config, escape hatch stays. services stops being a list you maintain and becomes a list of exceptions.

A queue and a topic are kinds too, and they are the case that shows why the mapping is keyed by kind rather than by config. They have no container of their own: they live in whichever backend the project selected, so config still picks which broker while the declarations decide whether one exists at all — selecting rabbitmq in a project that publishes nothing starts nothing. pg-boss, the default, has no container even then: its queues are tables in a database the manifest already declares, which is also why declaring a queue without a database is an error rather than a second Postgres nobody asked for.

Ports are allocated, and that is why several projects can run at once ​

Publishing 5432:5432 means the second project on your machine cannot start. Nothing solves that except giving each project its own ports — and the reason that is possible here is the single-<NAME>_URL rule. The app reads ORDERS_URL; it never sees a host or a port, so the local target is free to bind whatever is available and compose the URL from what it bound. An app that reads DB_HOST and DB_PORT separately can't be moved this way.

A shared cluster with a database per project is the tempting alternative and does not survive contact with reality: one project needs postgis/postgis:17-3.5, another postgres:18. One container cannot be both. Projects keep their own stacks.

Assignments are persisted, not recomputed. A hash of the project name picks a starting point unlikely to collide; from then on the assignment is stored, because recomputing means adding a construct renumbers its neighbours and every running container, saved connection string, and psql history goes stale at once. Allocation checks what is actually listening, so a collision with another project's stack surfaces at reconcile rather than as a failed connection.

The role DDL belongs to db, not to cloud ​

Roles have to exist locally too, or ORDERS_URL names a role that is not there. The design already says the migrator runs in-process locally and as a Lambda in the VPC; the same has to be true of role and schema creation. So the DDL lives in @geekmidas/db, imported by both the provisioner Lambda and gkm setup:

ts
// @geekmidas/db/pg/provision
export function statements(plan: SchemaPlan): string[]   // pure, testable without a database
export async function apply(master: string, plan: SchemaPlan): Promise<void>

Pure statement generation applied by whichever caller holds the master credential. One implementation, so there is nothing for the two paths to disagree about — which is what stops "works locally, fails in the VPC".

Three phases, and only one has side effects ​

phasecommandside effects
declaregkm build, dev's watchernone — constructs → manifest, pure
reconcilegkm setup, and gkm dev on startupports, passwords, compose file, containers, roles, env
rungkm dev, deployed handlersreads <NAME>_URL; provisions nothing

Reconcile is convergent, so "when does it run" has the comfortable answer: whenever, repeatedly. It computes the desired state, compares, applies the difference. gkm setup is that code invoked explicitly — for CI, for a teammate, for reconciling without starting a server.

Most of this already exists inside setupCommand: resolve secrets, write the docker env, resolve ports, start the services. What changes is where the list of services comes from — the manifest rather than a hand-written services: block — and that the same function is what gkm dev and gkm test both call, with the stage as its parameter.

gkm dev sets up automatically ​

It should, and the reason it is safe to is that reconcile's blast radius is entirely local: this project's containers, this project's .gkm/. Nothing it does can reach a cloud. Auto-running a deploy would be indefensible; auto-running this is not.

The alternative is that a fresh clone's first experience is ORDERS_URL is not defined, with a fix you have to already know about — which contradicts the zero-config principle directly.

The line is drawn at data, not at side effects:

automaticnever automatic
allocate ports, generate missing passwordsseed
write the compose filereset or drop
start containers, wait for healthdestructive migrations
create roles, schemas, search_pathanything targeting a non-local stage
run pending migrations

Migrations run because they are forward-only and the local database is disposable. Seeds do not, for the reason already given — running them automatically is how you overwrite data — and that argument is about intent, so it holds locally even though the stakes are lower.

Three things keep it from being annoying:

A fast path. Hash the desired plan; if it matches what was recorded and the containers are healthy, do nothing. Reconciling on every start is only acceptable if the converged case is free. That hash is the same mechanism the deployed side uses — aws.lambda.Invocation keyed on schema name, role names, and secret versions, re-running only when one changes. Both targets should call the same plan-hash function, so they cannot disagree about what counts as a change.

Saying what it did. One line when it acts, silence when it does not:

gkm: provisioned Reports (database, :27438), 2 migrations applied

Silent provisioning is how people stop knowing what state their machine is in.

An escape hatch and a decent failure. --no-setup for when you are pointed at a remote database or already have the stack running, and a sentence rather than a socket error when the docker daemon is not up.

One consequence: the watcher must re-reconcile when the manifest changes, and it currently misses file add/unlink. Today that costs a stale route. Here it means a newly declared database is never provisioned while the error you see says an environment variable is missing. It moves from a known bug to a blocker.

State lives in the thing it describes ​

Only two things need to be written down. Everything else already has an owner that cannot drift:

statewherewhy
roles and schemas existthe database — querieda file drifts the first time a volume is dropped
migrations appliedmigrations tablealready how migrators work
containers, volumescompose project labelsdocker already remembers
generated code, manifest.gkm/derived; safe to delete at any time
port assignments.gkm/ports.jsonnothing else remembers, and they must stay stable
generated passwords.gkm/secrets/<stage>.json, encryptedthe database stores only a hash

Both stores already exist. resolveServicePorts in packages/cli/src/credentials/index.ts already persists ports to .gkm/ports.json, reuses a saved port when it is still free, reuses a running container's actual port, and falls forward to the next free one when the default is taken — which is the whole of the allocation described above. What changes is only where the list of services comes from: the manifest, rather than a parsed docker-compose.yml.

Ports stay out of the encrypted store because they are not secrets, and passwords stay in it because they are. The key lives in ~/.gkm/<project>/<stage>.key.

Because roles are queried rather than recorded, losing the secrets file is recoverable rather than corrupting: reconcile regenerates the passwords and runs ALTER ROLE … WITH PASSWORD, which is the identical code path as rotation. The rare path is exercised by the common one. Ports are reallocated, which recreates the containers, but volumes are keyed to the project rather than the port, so the data survives.

One breaking change. StageSecrets.services is keyed by container — { postgres?, redis?, minio? } — which cannot express two databases and cannot express a schema tenant at all. Orders, an Auth tenant, and pg-boss are three role sets in one container. It becomes keyed by construct id:

ts
services: Record<string, ConstructCredentials>   // 'Orders', 'Auth', 'Uploads'
ports:    Record<string, number>                 // same keys

Fine on the v10 line, but it wants a read-old/write-new migration so nobody's development.json is silently orphaned.

Encryption in dev, and being able to read your own state ​

The encryption currently buys less than it appears to. The key sits next to the ciphertext, same machine, same user, no passphrase — so it is not defending against local compromise, it is not disk-at-rest protection, and it is not what prevents an accidental commit, since .gkm being gitignored does that. For a generated password to a container bound to localhost, it protects nothing.

It stays anyway, for a reason that is not uniformity: dev secret files routinely hold real third-party credentials. A Stripe test key is a live credential; an OpenAI key in dev is the same key as anywhere else. The store cannot know what a human pasted into it, so it assumes the worst.

What changes is that entries record provenance:

ts
interface SecretEntry {
  value: string;
  origin: 'generated' | 'supplied';   // reconcile made it, or a human did
}

Generated local values then print in full — they are useless off your machine and you need to paste them into psql — while supplied values mask unless --reveal. Today --reveal is one switch over both, which is why encryption feels like it is in the way: it is not, the reader is too blunt.

gkm status ​

gkm secrets:show answers "what secrets exist". Nothing answers "what state is my machine in", which is the question you actually have — and it is a question only the manifest can answer, because it is about constructs:

$ gkm status
development · 3 constructs · stack healthy

Orders           database          :27431   ✓ healthy
  ORDERS_URL     postgres://app:hunter2@localhost:27431/orders
  roles          app, app_owner    migrations  7 applied
Auth             database-schema   → Orders
  AUTH_URL       postgres://auth:s3cret@localhost:27431/orders
Uploads          objects           :27433   ✓ healthy
  UPLOADS_URL    s3://uploads?endpoint=http%3A%2F%2Flocalhost%3A27433

supplied secrets  STRIPE_SECRET_KEY, OPENAI_API_KEY   (--reveal to show)

What exists, what port it got, whether it is up, the URL to paste, and what Auth hangs off. --json to pipe it. It reads the cached manifest and warns rather than rebuilding when stale.

Values print in full for local stages and mask for every other one. Printing a production password because someone ran gkm status --stage prod on a shared screen is the failure worth designing out.

Not gkm credentials — that name is taken by the Dokploy login store, and two meanings of the word is one too many.

gkm test is the same reconcile, with a suffix ​

Tests need their own database, not their own stack. So gkm test is not a second mechanism — it is reconcile with a different stage:

gkm dev   = reconcile(manifest, 'development') → turbo run dev
gkm test  = reconcile(manifest, 'test')        → inject env → vitest

The stage names resources; it never names infrastructure.

gkm devgkm test
containerthe project's Postgresthe same one
portsallocated oncethe same
roles, schema, search_pathapp, app_owner, appthe same
databaseordersorders_test
bucket, queueuploadsuploads-test

What makes this nearly free is a property of Postgres: roles are cluster-scoped while grants are per-object. One app/app_owner pair and one ALTER ROLE … SET search_path cover both databases, because the schema is called app in each. Nothing is duplicated, and there is no second container to start, wait for, or run out of ports on.

It generalises past databases without a new idea: a bucket becomes uploads-test in the same MinIO, a queue orders-test in the same LocalStack. This is the local counterpart of cloudName(scope, id), which already scopes a deployed resource by stage.

rewriteDatabaseUrlForTests goes away. Today gkm test rewrites any env key containing DATABASE_URL by string-editing the database name onto the end. That matches nothing once constructs provide ORDERS_URL and AUTH_URL, so tests would quietly run against the development database. Provisioning replaces it: the test URL is composed from what was actually created, the same way every other URL is, which is also why the Postgres codec needs only build() and no parser.

Tests never commit, so the test database cannot drift. Every test runs inside a transaction that is rolled back, and Postgres rolls back DDL too — so even a test that creates a table leaves nothing behind. Two consequences:

  • No per-worker databases. Vitest runs files concurrently, and MVCC isolates concurrent transactions. One shared orders_test is correct, not a compromise.
  • The only durable writes are the migrations. So "does the test database need rebuilding" reduces to "have the migrations changed", which the plan hash already answers. gkm test --fresh forces it.

The construct is identical in both stages; only the injected value differs. That is what lets a test build its client through the same connect() production uses:

ts
const isolatedTest = isolator.wrapVitestWithTransaction({
  connection: orders,        // the construct — registered against process.env
  fixtures: { factory: (trx) => new KyselyFactory(builders, seeds, trx) },
});

isolatedTest('creates an order', async ({ trx, factory }) => {
  await adaptor.request({ database: trx, … });     // one door: trx is `db`
});

A hand-rolled test client is the failure this avoids: without the construct's plugins, it writes createdAt where the app writes created_at, and the test passes while production does not.

Boundaries ​

One application, one manifest ​

There is no apps: { … } block and no notion of standalone apps inside a workspace. A repository holds one application made of pieces — sites, surfaces, resources — discovered by one constructs glob into one manifest.

Surfaces are the deploy granularity, which is what separate apps were really providing. api, webhooks, and console deploy independently without a second grouping mechanism competing with the one that already exists.

A resource belongs to exactly one owner — that rule survives, relocated from "one app" to one manifest. Cross-manifest references stop being a convention and become unrepresentable: there is no syntax for pointing at a construct in another repository. Two genuinely separate systems are two repositories that share through APIs, which is where the boundary always belonged.

Sharing a resource across that boundary would mean the dependency edge crosses a deploy boundary — so neither side's manifest is self-contained, deploy order starts to matter, schema ownership becomes ambient, and derived IAM has to reach across accounts. The invariant worth keeping: every edge points at a construct the same manifest owns, which is what lets a surface deploy on its own.

The monorepo layout is unchanged — apps/console is still its own package with its own package.json. What disappears is the config-level claim that it is a separately deployable application.

Adoptable in pieces ​

Using the whole framework should be easy; using one part of it should also be possible. Every package stands alone, and the framework is the composition rather than the requirement.

The client packages already have this by construction — @geekmidas/storage, db, and cache depend on nothing from constructs, so someone who has never heard of a construct can use AmazonStorageClient with a hand-written config.

@geekmidas/cloud is the one at risk, because Stack.fromManifest is a tempting shape to build toward:

ts
new ObjectStorage(stack, declaration);              // ✗ framework-only
new ObjectStorage(stack, 'uploads', { versioned: true });   // ✓ ordinary props

If components take Declarations they stop working in a hand-written sst.config.ts. So components take ordinary props and stand alone; fromManifest is a translation layer over them. One consequence: assertProvides is a fromManifest-time check rather than something baked into the component, since a hand-constructed component has no declaration to compare against.

The same applies to @geekmidas/client — createAuthAwareFetcher works against any OpenAPI document, not only one gkm openapi produced. Cerberus already depends on that, since it bypasses the generated createApi to inject its own signing fetch.

Two fixtures keep this honest, alongside the existing constructs-only one: install just @geekmidas/cloud and build an sst.config.ts from the components with no manifest; install just @geekmidas/storage and use the client directly.

Packaging ​

In @geekmidas/constructs, behind one subpath per construct. A second segment appears only when the choice changes the code you write:

@geekmidas/constructs/object-storage       ← one import; S3 vs GCS changes no handler code
@geekmidas/constructs/database/kysely      ← the query builder is visible in every query
@geekmidas/constructs/database/knex

Never group by role (resources/) — resource and function are roles, not a partition, and Queue is both. And no barrels at any level, including @geekmidas/constructs/database, which would pull in both drivers and undo the gating.

The gating mechanism already exists: optional peer dependencies plus subpath exports. Clients never depend on the framework; constructs consumes them.

Three rules keep problem 5 fixed, and a fixture package that installs only@geekmidas/constructs and then type-checks and imports the root enforces them: the root export has zero runtime imports, optional peers are import type only, and nothing re-exports across a gate.

Refs, and what they're for ​

.dependsOn([sendEmail]) is a value import, and tree-shaking is module-granular — so importing the module that defines a function pulls its handler and full transitive tree into the caller's bundle.

ts
import type { sendEmail } from './send-email';        // erased
.dependsOn([Construct.ref<typeof sendEmail>('SendEmail')])

Construct.ref(sendEmail) taking the value cannot work: the value import is the coupling. Value imports stay fine for handler-less constructs — resources and topics are schemas and config.

targetdirect importwhy
serverfine, preferableone process, one bundle; the call is in-process
awsuse refseparate bundles; a direct import doubles the caller's Lambda

A generated .gkm/refs.ts (type-only imports throughout) removes the duplicated id string and makes the safe form the convenient one.

Later: pieces from different providers ​

DecisionDecided byWhen
I need object storage called uploadsapp codeauthoring
which adapter provisions it--target (+ per-resource override, later)deploy
the connection URLthe adapter, on provisiondeploy
which client driver runsthe build, per targetbuild

Per-resource targeting is a config change, not a redesign — the manifest says nothing about who provisions, so adapter selection is a lookup that can take a targets.overrides entry in the deploy layer. Putting { provider: 'gcp' } on the construct would bake a deployment fact into portable code.

The commoner case is a resource you never provisioned — Neon, Upstash, R2. That needs no multi-target machinery: Database.external('orders') declares the dependency, skips provisioning, and takes its URL from secrets.

The limitation: least privilege by construction works because an adapter can turn an edge into IAM within one cloud. A Lambda reaching a GCS bucket has no such mechanism — that edge degrades to an injected credential rather than a derived role.

Alternatives Considered ​

A. Constructs replace Service entirely. Cleaner end state, one mechanism. Rejected — breaks every app for a mostly aesthetic gain. The additive path reaches the same place and leaves this open.

B. Add resources to the manifest but keep sniffing. Fixes problems 1, 3, 4 cheaply. Rejected as an endpoint, adopted as an intermediate state: Phases 0–4 land there and Phase 5 finishes.

C. Generate constructs from workspace config. Rejected — config isn't typed against app code, so the rename problem returns, and it inverts the stated direction.

D. Ship each construct from its client package (@geekmidas/storage/construct). Rejected — inverts the layering rule, creates a storage → constructs → storage cycle, and forces splitting out a construct-core package.

E. Nest children under their surface in the manifest. Rejected — see The Manifest.

F. A separate WebhookApi. Rejected — a signature check is an authorizer, and modelling it that way removes the raw-body problem too.

Implementation Plan ​

Phase 0 — manifest contract. Declaration union, the id-keyed map, derived legacy projections, and the validation pass (collisions, reference integrity, route uniqueness, cycles). Touches: packages/manifest, packages/cli.

Phase 1 — the construct interface, and conform the existing constructs.Construct<TName, TClient>, .dependsOn(), Construct.fromService(), .name() on every builder except endpoints, immutable builders, .service as a field, the sniffer moved out of the base, the single constructs glob. Endpoint is the proof — if the interface can't express it, stop here. Touches:packages/constructs, packages/cli.

Phase 2 — first vertical slice: ObjectStorage. declare() → manifest → derived Service → Stack.fromManifest provisions and derives the link and IAM from the edge → gkm docker emits MinIO → build-time driver pinning. This is the phase that validates or kills the design.

Phase 3 — KyselyDatabase. Its own slice, not a third of one: the ALS connection resolver, the bound-method proxy, $pool, and .database() retargeted. Acceptance: new KyselyDatabase<OrdersDB>('orders') alone types db in handler and session extractor, with the hand-threaded builder type parameters gone.

Phase 4 — surfaces. RestApi with authorizers, Topic/Queue retrofitted onto the interface (constructor-assigned service, no default logger, publisher naming), e as a default RestApi, s deprecated. (Superseded: v10 removed e and s outright, and Topic/Queue expose their publisher as service.)

Phase 5 — Cache, Secret, and declaration-first env. Sniffing narrows to fromService lifts and the deprecated .services().

Phase 6 — retire the third declaration. Derive docker-compose from the manifest and deprecate the workspace services: block.

Follow-ups: KnexDatabase (needs @geekmidas/db/knex first), generated .gkm/refs.ts, per-resource targeting.

Testing Strategy ​

  • Golden manifest tests. A fixture app with one of each kind, asserted against a checked-in snapshot — contract drift caught in the one place three consumers read from.
  • Round-trip config keys. For every built-in construct, declare() keys and connect() keys must be identical. This is the drift guard, enforced.
  • Filtering, as exclusion. Three constructs declared, a route depending on one: assert its links contain exactly that one, and that adding an unrelated construct leaves every existing route's link set byte-identical.
  • Transaction scoping, interleaved. Two concurrent requests must see their own transactions through the same captured services.orders; a throwing handler rolls back; nested withTransaction reuses; $pool writes survive.
  • Logger staleness, interleaved. A singleton client capturing the logger at register() must produce entries with distinct request ids.
  • Validation tests. Each rejection — id collision, dangling reference, duplicate route, cycle — with an assertion on the message, since the message is the feature.
  • Type tests. services.<id> inference; a plain Service in .dependsOn() fails to compile; .database(new KyselyDatabase<OrdersDB>('orders')) types db in handler and session extractor, with an unknown table failing to compile — that's what proves the schema flowed rather than degrading to any.
  • Package isolation, three fixtures. Install only @geekmidas/constructs and type-check its root; install only @geekmidas/cloud and build an sst.config.ts from the components with no manifest; install only @geekmidas/storage and use the client with a hand-written config. These are what stop the framework quietly becoming a requirement.
  • Sniffer regression. The existing getEnvironment suite passes unchanged through Phase 5 — proof this stayed additive.

Impact Analysis ​

PackageImpactNotes
manifestContract changeNew Declaration union and id-keyed map; legacy fields derived
constructsCoreInterface, .dependsOn(), surfaces, request-scoped clients; base loses 7 of 9 type parameters
cliModerateSingle glob, validation pass, Stack.fromManifest inputs, driver pinning, compose derivation
cloudModerateProvisions by kind; links/env/IAM per function from edges; adds Database/Cache/Secret components
envkitMinorResourceType ↔ kind mapping; resolvers already exist
dbAdditive./knex module for the KnexDatabase follow-up
storage, cache, eventsNoneConsumed by constructs; stay standalone
servicesNoneService unchanged and still public
clientBug fixiam strategy is a silent no-op; needs the header/signing phase split
testkitSimplificationFour bespoke test adaptors collapse toward one that swaps transport, not interface

Layering rule: the client packages depend on nothing from constructs. constructs consumes them, never the reverse.

Open Questions ​

  1. Where does Secret sit? It overlaps gkm secrets:* and Credentials, which already provision and inject. A Secret construct likely declares which secrets a function needs rather than provisioning storage — making it the odd kind out. Settle in Phase 5.

  2. Does the bundling story hold? ref is designed to keep a callee's handler out of the caller's bundle, but it needs a real bundle-size measurement in Phase 2. If it doesn't hold, function→function edges must require the thin reference rather than accepting it as an option.

  3. When does per-resource targeting land? The door is open (a targets.overrides lookup, no manifest change) and should stay closed until something forces it — every adapter then has to answer "what if my dependency lives elsewhere", and the answer at provider boundaries is inject a credential, not derive a role.

  4. Who provisions the read replica? orders.reader() declares a dependency on a reader endpoint but does not say whether the adapter creates a replica or points at an existing one — and on --target=server there may be no replica at all, in which case the reader URL should resolve to the writer rather than fail. Everything else about readers is settled: they are a vended construct (Construct<'OrdersReader', Kysely<OrdersDB>>) providing their own single ORDERS_READER_URL, so provides needs no second key and the edge needs no access attribute. A function depending only on the reader gets read-only IAM — read-only by construction.

  5. Manifest diffing for renames. Renaming an id renames deployed infrastructure, destructively for stateful kinds. Diffing the built manifest against the last deployed one would let the build name the cause ("Uploads → UserUploads: export renamed; this replaces bucket prod-myapp-uploads"), which no amount of naming ceremony can do. There is a per-stage state precedent in deploy/state.ts, though it stores Dokploy resource ids rather than manifest snapshots. On AWS, Pulumi's preview already shows the destroy/create — what it cannot show is why.

Settled, previously open: reader/writer no longer needs an edge attribute (see 4); RLS and audits apply to the connection passed to .database() and nothing else — anything obtained through .dependsOn() is a raw client you manage yourself, the same contract as db.$pool. Note that a raw dependency does not carry RLS context; a correctly written policy fails closed when the setting is absent (current_setting('app.x', true) returns NULL), so this is a boundary rather than a hole.