Skip to content

Latest commit

 

History

History
135 lines (102 loc) · 26.8 KB

File metadata and controls

135 lines (102 loc) · 26.8 KB

Plan: @nestm/permissions — Cedar-backed, AWS-IAM-style authorization for NestJS 12

Context

Kauan builds the @nestm/* family of NestJS 12 libraries (standard-schema, better-auth). The next library is a granular, AWS-IAM-like permission system: policy-based (Allow/Deny, default-deny, deny-overrides), generic across projects, with ORM drivers, and adoptable by /Users/kauan/Projects/concepta/station (NestJS 12 + Fastify + Drizzle/Postgres FORCE-RLS), which today has a deliberate but closed-enum permission system (ADR-0004) whose framework-free core lives in packages/platform/src/authorization.ts.

Recon (live-verified 2026-07-30) showed no maintained JS library implements IAM policy documents, and no NestJS module exists for Cedar/Cerbos at all. The genuine ecosystem gaps this library fills: policy-based NestJS authz with DB-backed runtime-editable policies and ORM row-filtering (unserved for TypeORM by anyone; barely for Drizzle).

Decisions (user-locked)

  • Engine: wrap Cedar@cedar-policy/cedar-wasm (nodejs build). AWS's own formally-verified policy language: permit/forbid, default deny, forbid-overrides-permit, conditions, hierarchies, schema validation.
  • Monorepo nestm-dev/permissions, split packages (pnpm workspace + changesets fixed mode, pre-mode alpha):
    • @nestm/permissions-core — framework-free Cedar engine wrapper (zero NestJS deps)
    • @nestm/permissions — the NestJS 12 module
    • @nestm/permissions-typeorm, @nestm/permissions-drizzle — storage drivers + query-filter compilers
  • v1 scope: core + Nest module + in-memory store + three-state query-plan API (ALWAYS_ALLOW / ALWAYS_DENY / CONDITIONAL) + both ORM drivers + station migration plan.

Load-bearing facts (executed live against cedar-wasm@4.12.0, Node 24 — not from docs)

These bind the implementation; full evidence in the design docs (see References):

  1. ESM named imports work natively from @cedar-policy/cedar-wasm/nodejs (nested "type":"commonjs" + cjs-module-lexer). WASM loads synchronously; no await init(). Must be deps.neverBundle in tsdown (uses __dirname/require('fs')).
  2. statefulIsAuthorized is 14.6× faster than isAuthorized (0.136 vs 1.98 ms/op at 40 policies) → preparse caching is mandatory.
  3. isAuthorizedPartial (2.28 ms/op) cannot use the preparse cache (no preparsedPolicySetId field) → a JS-side plan LRU is a requirement, not an optimization.
  4. Entity graph size dominates latency (500 irrelevant entities → 20×) → pass principal + ancestors + resource only.
  5. No WASM unregister API; distinct preparse ids leak (+126 MB / 2000 ids) → policy-set ids must be stable per scope, never version-suffixed; re-preparsing the same id frees the old (+6 MB); eviction = overwrite with an empty policy set (verified to free).
  6. Partial-eval residuals: three-state comes directly from decision: 'allow'|'deny'|null; unknowns appear as {"unknown":[…]} ext-calls inside conditions; >/>= arrive negation-normalised; like patterns arrive tokenised (carry tokens into the AST — never re-serialise; that's the %/_ injection trap).
  7. An errored policy gets a {"Value":false} residual — an errored forbid silently disappears. Sharpest security edge: plan() throws ErroredPolicyError by default when errored[] is non-empty.
  8. Template links are honoured by partial eval → Cedar templates + links are the "role grant" primitive for both check and plan.

Architecture

@nestm/permissions-core (design: design-core.md)

  • PermissionsEngine<V> class (PermissionsEngine.create() async factory): check/checkMany/plan/warm/invalidate/validatePolicies/stats/dispose. Generics thread a vocabulary V so ActionOf<V> is a string-literal union and ResourceTypeFor<V, A> makes a mismatched action/resource pair a compile error.
  • Typed vocabulary builder defineVocabulary({ namespace, entities, actions }) — one source, two outputs: Cedar SchemaJson (fed to preparseSchema) + TS unions. Cedar action ids are arbitrary strings, so station's existing noun:verb permission keys map 1:1 with no rename. Action groups supported in v1 (Cedar has no action wildcards; memberOf groups are the run:* idiom — cheap now, expensive to retrofit).
  • PolicyStore SPI: load(scope) → PolicyBundle{version, policies, links}, currentVersion, save/delete/linkTemplate/unlinkTemplate, optional watch. Policies persist as PolicyJson (jsonb-queryable; policyToText for admin UIs). MemoryPolicyStore ships in core.
  • Multi-tenant scoping: PolicyScopeId per tenant (e.g. org:<uuid>); WASM id = `${instanceId}:${scope}` stable forever; JS LRU (default 256 scopes) with evict-by-empty-overwrite; single-flight cold loads; version-stale check via currentVersion. Global + tenant policies are composed at load time by the store (bundle = global ∪ scope), so per-check cost stays one call; a global change invalidates '*'.
  • EntityProvider SPI + typed entity() builder that rejects attributes absent from the vocabulary (catches the errored-forbid trap at construction time). Two-tier entity cache (per-request map + optional process LRU).
  • Query plan — the security-critical part:
    • Neutral PlanNode AST (and/or/not/cmp/in/contains/like[tokens]/exists/isEmpty/isType/inHierarchy), typed PlanValue variants (drivers must bind parameters, never interpolate).
    • Compile pipeline: partition residuals by effect → normalise (constant folding, negation pushdown, unknown detection) → translate per the pushdown table → assemble OR(permits) AND NOT(OR(forbids)) → simplify.
    • Fail-closed contract: untranslatable subterm in a permit would widen the row set → unsafe → throw (unsupportedResidual: 'error' is the default) or attach an explicit O(n) postFilter (opt-in, capped at 500 rows). Untranslatable subterm in a forbid widens the forbid → result is a subset → safe, recorded as a PlanApproximation{direction:'restrictive'}. Direction is one tested function of (effect, polarity) — a sign error here is a CVE.
    • evaluatePlanNode reference interpreter exported from @nestm/permissions-core/testing so ORM drivers differential-test their SQL against the same oracle. (Family divergence: this package family allows a ./testing subpath.)
  • cedar-wasm is a hard dependency pinned exactly 4.12.0 during alpha (residual shapes are experimental/unversioned); a non-blocking CI canary tracks @latest. CedarBinding interface + memoised lazy loadCedar() keeps 4.1 MiB WASM out of the module graph until engine creation (drivers import types + AST walker only).
  • Error taxonomy (PermissionsError + code union) carrying Cedar DetailedError[] (enables editor squiggles in an admin UI); onDecision audit hook (synchronous, try/catch-wrapped, context redacted by default).
  • Deferred to v1.1: reverse planning (principal: null — "who can act on this row"; verified to work).

@nestm/permissions (design: design-nest.md)

  • Follows better-auth for every family divergence: verbatimModuleSyntax:false, tsdown (never esbuild — CI greps design:paramtypes in dist), .ts import extensions, tabs/width-100 prettier, real .oxlintrc.json.
  • Module: ConfigurableModuleBuilder 1:1 with better-auth.module-definition.tsforRoot/forRootAsync (createPermissionsOptions factory), setExtras({isGlobal:true, disableGlobalGuard:false}) auto-registering APP_GUARD → PermissionsGuard; Symbol tokens (PERMISSIONS_MODULE_OPTIONS, AUTHORIZATION_ENGINE, POLICY_STORE, PRINCIPAL_RESOLVER). Two-phase init: engine factory (no I/O) + PolicySetManager (OnModuleInit load, reload(), poll interval, explicit 'unloaded'|'ready'|'stale' state machine). Guard calls assertReady() → 503, never fail-open. forFeature({ entityProviders }) with DiscoveryService-based @EntityProvider class discovery (per-feature entity-graph knowledge).
  • @RequirePermission(action, resource?, options?) with ResourceRef union: param (with parseAs Standard Schema codec — guards run before pipes, so params are validated in-guard; station's Zod branded schemas drop straight in), literal, resolver, and unspecified (triggers the query plan; ALWAYS_DENY → 403, else plan stashed on the request). Typed actions via declaration-merged PermissionsTypeRegistry (falls back to string unaugmented; both branches type-tested).
  • 404-vs-403 (station ADR-0014): per-route scope pre-check (non-membership → 404 with constant body; member-lacking-permission → 403), onDeny: 'forbidden'|'not-found', hooks.onDenied returning an Error overrides everything. E2E asserts byte-identical 404 bodies for unknown-org vs non-member-probe.
  • Boot-time route audit (generalised from station): routeAudit.mode 'off'|'warn'|'error' (default off), additionalMetadataKeys as the station-migration escape hatch so both decorator families count during cutover.
  • Singleton PermissionsService (never request-scoped); @CurrentPrincipal/@CurrentAuthorization/@QueryPlan param decorators reading request[AUTHORIZATION_STATE]; RequestAuthorization.planFor() for imperative planning.
  • Auth interop without coupling: RequestPrincipalResolver({ property, map }) reads request.session/user/identity — recipes for @nestm/better-auth, plain JWT, and station. Auth guard must be registered before the permissions guard (documented; station already relies on APP_GUARD order).
  • Transport scope v1: http + graphql; ws/rpc get correct exception types but param refs throw a configuration error there (documented limitation).
  • Barrel re-exports types only from core (QueryPlan, EntityUid, PolicyStore…); engine construction stays core-only.

Monorepo scaffolding (design: design-nest.md §1)

  • Workspace packages/* + examples/*; root solution-style tsc -b; changesets fixed across all four (one physical copy of core — two copies = two WASM instances = sporadic "unknown policy set"; core also registers Symbol.for('@nestm/permissions-core/instance') on globalThis and errors on duplicate load). Core is a plain dependency (workspace:^) of the other three — not a peer.
  • "Core stays Nest-free" enforced twice: scripts/assert-core-framework-free.mjs (static grep) + CI job installing the packed core tarball into a bare dir with zero @nestjs/* and importing it.
  • CI: check / per-package build (publint --strict + attw esm-only + design:paramtypes grep on the Nest package) / test matrix node 22,24 × express,fastify / test-drivers with services: postgres:16-alpine / core-framework-free / non-blocking canary (@nestjs/*@next and cedar-wasm@latest — load-bearing given experimental partial eval).
  • Release: standard-schema's hardened scripts/publish.mjs adapted for multi-package (assertFixedVersions, tag reconciliation loop). npm Trusted Publishing OIDC; first alpha published manually per package then bound via npm trust.
  • License/author: BSD-3-Clause © nestm (user-confirmed — matches better-auth; standard-schema retrofits later).

@nestm/permissions-typeorm / -drizzle (design: design-drivers.md)

Core SPI deltas (accepted, must land in core before drivers start):

  • D1 watch() owns freshness — the engine never calls currentVersion() on the check path (a DB round-trip per check destroys the 0.136 ms number).
  • D2 load(scope) returns the effective bundle (global '' ∪ scope); PolicyChangeEvent may carry scope:'*'; composite version g<n>:s<m>; global/scope policy-id collisions throw.
  • D3 defineVocabulary must NOT eagerly load WASM (validation moves to engine.create()) — station's packages/web transitively reaches packages/platform; 4.1 MiB bundle regression otherwise.
  • D4 Ship CompositePolicyStore in core ('instance' → memory store, '*' → DB store).
  • D5 Export pure AST utilities from a @nestm/permissions-core/plan subpath (walkPlanNode, likeTokensToPattern, planValueKindOf) — duplicated LIKE-escaping across drivers is how you get a %-injection bug in exactly one of them.
  • D6 TemplateLinkRecord.values tolerates a missing ?resource (runtime-checked against the template's declared slots).
  • D7 evaluatePlanNode's HierarchyResolver is required whenever the tree contains inHierarchy, and throws when unresolvable.
  • Nest delta: PrincipalResolver.resolve() can return {kind:'not-in-scope'} distinct from null — that's what maps station's "authenticated but not a member" onto the 404 path without a Cedar decision.
  • Entity-cache invalidation is tied to PolicyChangeEvent per scope (stale ancestors → stale plan → over-share); asserted in the store conformance suite.

Storage schema (both drivers, tablePrefix default permission_): permission_policies (static + templates in one table; cedar_json jsonb canonical + denormalised cedar_text for admin UIs; partial index (scope) WHERE enabled; GIN on cedar_json), permission_policy_links (slot values as columns — indexed "revoke everything for member X"; PK (scope, link_id)), permission_scope_versions (monotonic counter bumped in the same transaction as every write). Scope column is consumer-supplied via ScopeColumnOptions (name, toScope/fromScope, supportsGlobalScope:false for NOT-NULL tenant columns like station's) — that's what makes the tables droppable into an RLS regime with no NULL-tenant escape hatch. Optional permission_principal_groups behind a flag (default off). Invalidation v1: synchronous local event after commit + one-query monotonic-version snapshot poll (default 5 s, O(all scopes) returned); LISTEN/NOTIFY opt-in on a dedicated non-pooled connection.

Drivers: TypeORM uses EntitySchema factories, not decorated classes (no experimentalDecorators requirement, runtime-renameable tables, sidesteps design:paramtypes fragility); three-tier migrations story (register entities + migration:generate / buildPermissionsMigration() raw statements for hand-appending GRANT+RLS / PermissionsInitialMigration factory). Peer typeorm ^1.1.0 only (user-confirmed; 0.3.x support added later if demand shows). Drizzle exports a createPermissionsSchema factory — results must be spread into top-level named consts (verified: drizzle-kit's prepareFromExports only sees top-level PgTables; nesting silently yields an empty migration — README in bold); extraTableConfig is the RLS seam (pgPolicy/FKs/indexes pass through); permissionsPostgresPolicyStatements() returns the GRANT/RLS strings to hand-append. Both ship NestJS wiring as a ./nestjs subpath with optional @nestjs/* peers.

Query-filter compilers: planToSql(plan, mapping): SQL / planToBrackets(plan, mapping): Brackets are total functionsALWAYS_ALLOW → TRUE, ALWAYS_DENY → FALSE; no API can return "nothing" (absent WHERE = every row). Mapping DSL: attributes (scalar/entity/array/jsonPath) + hierarchy (self/column/closure/recursive); TypeORM resolves property paths through EntityMetadata.findColumnWithPropertyPathStrict (typo = hard error; identifiers via qb.escape, never user input). Key correctness pins: Cedar in is reflexive (self mapping compiles to id = $p — wrong = silent over-block); LIKE patterns bound with ESCAPE parameter, compiler throws on case-insensitive collations (Cedar is case-sensitive; citext silently over-matches); SQL NULL three-valued logic is uniformly restrictive under OR(permits) AND NOT(OR(forbids)) — never wrap in COALESCE(…, true), never NOT IN a nullable subquery; bigint binds as string + ::bigint cast; TypeORM parameter names collision-proofed. Every unmapped/unsupported node throws a typed PlanCompilationError — "there is no configuration in which an uncompilable node becomes TRUE". Vendor @ucast/sql under references/ as prior art; do not depend on it.

Driver testing: three-way differential (brute-force check() vs evaluatePlanNode vs real Postgres 16 rows — set equality) with fast-check; LIKE fuzz vs Cedar's own decisions; fail-closed table; shared store conformance suite exported from core ./testing run against all three stores; RLS harness reproducing station's FORCE-RLS shape; injection corpus. CI services: postgres:16-alpine + repo compose.yaml (testcontainers rejected — heavy dep, diverges from station's blackbox pattern).

Station migration (design: design-drivers.md §4)

  • Vocabulary: stationVocabulary in packages/platform/src/authorization/vocabulary.ts — all 25 permission keys + organization:create become Cedar action ids verbatim (the closed Zod enum in @station/contracts stays the source of truth; CI asserts ActionOf<vocab> ≡ PermissionKey ∪ InstancePermissionKey). Entity hierarchy Organization ← Project ← {Repository, Board, Run, Secret} ← … means a project-scoped grant transitively reaches Runs/Gates/Artifacts — an intended behaviour change on a security boundary: shadow mode will surface it as a divergence class that must be reviewed and accepted with a test, not dismissed.
  • Roles → templates, grants → links: one Cedar template per roles row (@id("role:<id>"), permit(principal == ?principal, action in […bundle…], resource in ?resource)); role_grants.id reused as link_id (idempotent backfill, revoke = delete same key). role_grants stays the source of truth; permission_policy_links is a same-transaction projection — preserves the whole Role/RoleGrant OpenAPI contract, the generated web client, and the advisory-lock "last administrator" protection. Composite FK links(org,link_id) → role_grants(org,id) ON DELETE CASCADE makes dangerous-direction drift structurally impossible (+ CI drift script). Requires adding UNIQUE(organization_id, id) on role_grants. Operators: no table change — separate 'instance' scope on a MemoryPolicyStore (D4) with one code-owned policy over Group::"operators", membership via an EntityProvider reading the existing operators table.
  • Guard swap: mapping table for all five scope kinds (organization→param ref, project→param ref, anyunspecified (query plan), membershipunspecified + batched checks, instance→literal). 404-vs-403 preserved exactly: the 404 is a principal-resolution outcome (not-in-scope when no Member row), not a Cedar denial; hooks.onDenied returns station's constant-detail NotFoundException. parseAs: organizationIdSchema (Zod 4 is Standard Schema) preserves guard-before-pipes validation with the same error pointers. Dual-decorated routes + both audits during cutover (additionalMetadataKeys: [ROUTE_PERMISSION]); Better Auth organizationHooks (non-guard path, ADR-0016) → permissionsService.check().
  • New tables under RLS: 0004_cedar-policy-store.sql sketched in full (FORCE RLS + isolation policies + GRANTs, following 0003_audit-trail.sql). The one honest RLS conflict: the invalidation poller queries permission_scope_versions with no org context — user-approved: no RLS on that table (it holds only a change counter, no org data; station_app already knows every org id it serves) — record the carve-out and its rationale explicitly in ADR-0019 + SECURITY.md during S1.
  • PermissionReach → query plan: listProjects uses planToSql(plan, projectResourceMapping) (org-wide residual → organization_id = $o, project grants → id IN-style ORs — identical semantics, RLS still the second wall); listOrganizations goes from N transactions to one withIdentityContext query + N in-memory ~0.14 ms checks.
  • Phasing with rollback: Phase 0 additive (tables + projection writes + backfill; reversible by dropping 3 tables) → Phase 1 shadow (STATION_AUTHZ_ENGINE=shadow, legacy decides, Cedar compares, divergence counter; exit gate = zero divergences on full blackbox suite + staging soak) → Phase 2 cutover route-family-by-route-family → Phase 3 query plans → Phase 4 delete legacy (platform evaluation fns, guard, decorator, audit; roles/role_grants/contract stay). Rollback = flip the env flag any time before Phase 4. Must-keep-passing: all 9 authorization.blackbox.test.ts cases, tenancy + invitations blackbox, route-audit test, pnpm contracts:generate boots, git diff --exit-code on the generated client.

Implementation order

Phases gate on each other; core check-path first, plan compiler before drivers.

  1. Repo scaffold (~2 d): workspace, shared configs, changesets fixed+pre, publish scripts + specs, CI/release workflows, four package skeletons building green.
  2. Core check path (~7 d): CedarBinding/loader/uid → vocabulary builder + type tests → PolicyStore SPI + MemoryPolicyStore + codec → policy-set cache (stable ids, evict-by-empty, single-flight) → EntityProvider + entity() builder → check/checkMany + errors + onDecision + stats. Shippable check-only milestone; unblocks the Nest package.
  3. Nest module (~12 d, overlaps phase 2 after the contract stub lands): tokens/module-definition → providers + PolicySetManager → decorators → resolvers → @EntityProvider discovery + forFeaturePermissionsGuard (12-step flow; highest-risk file) → PermissionsService + param decorators → route audit → barrel + exports test → unit + e2e (both adapters) → README + example app.
  4. Query-plan milestone (~8 d): PlanNode AST + normaliser + pushdown table + direction analysis + compile-residualsevaluatePlanNode + ./testing entry → property-based soundness suite (set-equality vs brute force; fail-closed mutations), LIKE fuzz, direction matrix → Cedar corpus conformance runner → benchmarks. Gates both drivers.
  5. Drivers (~16.5 d): core deltas D1–D7 + shared store-conformance suite first → Drizzle first (simpler; shakes out AST↔SQL semantics): schema factory + drizzle-kit round-trip test → store + watcher → compiler → differential/fuzz/RLS/injection suites → ./nestjs module → then the TypeORM mirror (EntitySchema factory, migrations tiers, EntityMetadata column resolution, parameter collision-proofing) → CI test-drivers wiring.
  6. Station migration (~13.5 d, phased S1–S10 with shadow mode and env-flag rollback): ADR-0019 + SECURITY.md → vocabulary + CI enum-equivalence test → 0004 migration + role_grants composite unique → transactional projection writes + backfill/drift scripts → engine module (CompositePolicyStore, principal resolver, entity provider) → shadow service + divergence metric → guard swap (dual decorators, dual audits) → query plans for tenancy lists → Better Auth hooks → delete legacy + rewrite permission.guard.test.ts.

Settled design calls (from the three slices' open questions — recorded so implementation doesn't relitigate): all core deltas D1–D7 + the not-in-scope principal-resolver delta accepted; ./testing and ./plan subpaths allowed as a family divergence for this repo; cedar-wasm exact-pinned as a hard dependency; unsupportedResidual defaults to 'error'; action groups in v1, reverse planning deferred to v1.1; global+tenant bundle composition at load time; drivers ship ./nestjs subpaths with optional peers; CI service containers + compose over testcontainers; GET /organizations keeps the batched per-org loop; routeAudit default 'off'; Nest barrel re-exports core types only; transports v1 = http + graphql; minimal example app now, station-derived one after migration; TypeORM driver documents Node >=22.13 (typeorm@1.1.0's engine floor).

Verification

  • pnpm -r lint/format/typecheck/build; per-package publint --strict + attw; design:paramtypes grep; core-framework-free black-box import; e2e matrix express+fastify on node 22+24.
  • Security invariants as tests: no-authz-decorator route → 403; audit mode:'error' refuses boot; unready policy store → 503 everywhere; byte-identical 404 bodies; plan property-tests assert set-equality against brute-force check() and that non-pushdown-able policies throw rather than plan; LIKE fuzz against Cedar's own decisions; direction-analysis exhaustive matrix.
  • Drivers: three-way differential set-equality (check / interpreter / real Postgres), LIKE fuzz vs Cedar, no-input-produces-absent-WHERE property test, store conformance suite across all three stores, RLS harness, injection corpus.
  • Station (per phase): db:generate produces an empty diff after 0004; unit + blackbox suites green; contracts:generate boots (route audit); git diff --exit-code on generated client; projection drift script = 0; shadow-mode divergence counter = 0 before cutover; explicit new test for the transitive-in reach change; poller-kill staleness ≤5 s and never widens access.

Top risks

  1. isAuthorizedPartial experimental, residual shapes unversioned → exact-pin cedar-wasm, corpus + property suites as dependency-bump tripwire; check() never depends on partial eval (a regression degrades filtering, never enforcement).
  2. Unsound plan compiler = wrong rows → fail-closed default, single tested direction function, set-equality property tests, shipped reference interpreter for driver differentials.
  3. Errored forbid disappearing → ErroredPolicyError by default, typed entity builder, validateOnLoad.
  4. WASM memory monotonicity → stable ids, evict-by-empty, maxScopes sizing table in README.
  5. NestJS major-version churn → stable ^12.0.0 peers plus the non-blocking @nestjs/*@next canary.
  6. Uncompilable plan node silently becoming TRUE in a driver → total compile functions, typed throws for every unmapped case, differential suites; ALWAYS_DENY compiles to literal FALSE.
  7. Cedar's transitive in widens project grants beyond station's literal param matching → intended, but treated as a security-boundary change: shadow-mode divergence class, reviewed + tested explicitly.
  8. Projection drift role_grantspermission_policy_links → same-transaction writes + composite ON DELETE CASCADE FK + CI drift check.
  9. permission_scope_versions RLS carve-out weakens station's "every org-scoped table is RLS-forced" invariant → user-approved; must be documented in ADR-0019 + SECURITY.md with the cache-coherence-channel rationale.

References (full design docs, on disk)

  • Recon reports + designs: /private/tmp/claude-501/-Users-kauan-Projects-nestm/e37497dc-4876-466e-8b62-3348b9de7490/scratchpad/{nestmLibs,station,iamEngines,tsEcosystem,design-core,design-nest,design-drivers}.md (+ .risks.md / .questions.md companions)
  • Durable copies: workflow journals under /Users/kauan/.claude/projects/-Users-kauan-Projects-nestm/e37497dc-4876-466e-8b62-3348b9de7490/subagents/workflows/{wf_466bb71e-ef3,wf_b4b94f77-8fb}/journal.jsonl
  • First implementation task: copy the design docs into the new repo as docs/design/*.md so they survive this session.