Skip to content

Opt-in store provisioning: safe, slice-scoped schema deploys confined to an app's own namespaces #109

Description

@FelineStateMachine

Motivation

#107 makes the sync location user data: the user brings a store (e.g. a lofi-node app ticket), and the app connects to it. Once stores are user-supplied, one store is shared infrastructure — otherapp.notes may already live in the Jazz db when taskapp enrolls. The store's deployed schema must therefore grow slice by slice, and an app must be able to create/update its own slice without being able to damage anyone else's.

Two principles fall out:

  1. Store changes are user opt-in. Enrolling a sync ticket attaches transport only and never mutates the store. Creating or updating the store's schema is a separate, explicit election, gated by the store's admin secret (which is exactly what the user supplies when opting in — lofi-node app tickets gate transport; X-Jazz-Admin-Secret rides on top for administration).
  2. A sliceable app inherently wants the safe migration. An app's provisioning may create/drop/rename only tables under its own namespace prefixes (taskapp__* for an app declaring taskapp). Modifying any table outside its appslug is disallowed by the framework. This keeps apps honest: N apps can share one store, each owning its slice, none able to clobber a sibling through the framework surface.

Validated groundwork (2026-07-18, alpha.53, live JazzServer experiments)

The catalogue supports this model today; the whole lifecycle was proven experimentally:

  • Deploy app A's slice → later deploy the union (A+B) with s.defineMigration({ from, to, createTables: { notesapp__notes: true } }) → schema, migration, and permissions bundle all publish; the permissions head advances as a chained version (parentBundleObjectId); the old-hash client keeps working untouched; the union-hash client uses both namespaces.
  • migrate: {} alone is not enough: pushMigration throws MissingMigrationError for any table-set change with empty forward lenses. Added tables must be covered by createTables (or renameTables), dropped ones by dropTables — so the framework must own migration generation.
  • A deploy missing its migration is safe by construction: the schema is stored but the response reports migration: "missing" and the new permissions bundle is withheld — the store's current users are unaffected.
  • Failure surfaces, as observed:
    • No schema deployed → client writes hang indefinitely (no error). Preflight is mandatory; write-and-hope is not an option.
    • Disconnected hash: server-origin warning naming both hashes ("Your declared schema X is disconnected from the schema used to enforce permissions Y … add a migration"). Writes to tables covered by the enforced permission schema still work; writes to uncovered tables fail cleanly (permission_denied: … table unavailable in current permission schema).
  • computeSchemaHash is JSON.stringify-key-order-sensitive. A merge must be computed over the stored head schema fetched verbatim (GET /apps/<id>/schema/<hash>), appending/updating this app's tables in that exact object — never by recomputing the union from local source.

Design

Part A — Preflight and store-state classification

A classifier that fetches the store's head (admin-gated when invoked for provisioning; passively inferred from sync signals otherwise) and reports one of:

  • ok — the app's slice, at the app's expected version, is in the enforced schema.
  • no_schema — nothing deployed. Remedy: opt-in create.
  • schema_out_of_date — the store lacks the app's slice, or holds an older version of it reachable through the app's own migration lineage. Remedy: opt-in update.
  • schema_drift — the store's copy of the app's own namespaces differs in a way the app's migration lineage cannot explain (another writer changed it, or incompatible shapes). Never auto-repaired; surfaced with both shapes.

Boot diagnostics consume the same classification so "hanging writes against a schema-less store" becomes an impossible state.

Part B — Opt-in provisioning (create/update)

provisionStore (name TBD): user supplies the admin secret; the framework fetches the stored head schema, produces the union by appending/updating only this app's slug tables in the stored object (byte-order preserved for hash stability), generates the migration (createTables/dropTables/renameTables + the app's own intra-slice column migrations), publishes schema + migration + union permissions, and reports the advanced head. First-time create is the same flow minus the migration.

Part C — The appslug safety invariant

Enforced by the framework at generation time, before anything is published:

  • The provisioning diff may only touch tables whose mangled prefix is among the app's declared namespaces (its defineNestedApp keys). Any generated createTables/dropTables/renameTables/column operation naming an out-of-slug table is a hard error, not a warning.
  • Tables outside the app's namespaces are preserved verbatim from the stored schema — bytes, order, and policies.
  • Cross-namespace renames (the Part-1 renameTableFrom move) are allowed only when both namespaces are declared by the app.
  • The app's permissions contribution is limited to its own slug tables; sibling slugs' policies carry through unchanged into the union bundle.
  • Drift detection is scoped the same way: the app compares only its own namespaces and treats the rest of the store as opaque.
  • A flat (defineApp) schema has no slug and therefore no tenant boundary: it may provision only a store it wholly owns (no_schema create, or a head consisting solely of its own tables). Sharing a store requires the nested form — the slug is what makes honesty enforceable.

Trust boundary, stated honestly: the admin secret technically permits anything; this invariant keeps apps built on lofi honest through the framework surface. It is a correctness/hygiene boundary, not a defense against a hostile admin-secret holder (who can already do anything to the store).

Open questions

  • Sibling policy recoverability. The union permissions bundle replaces the head, so a merge must include sibling slugs' policies. The stored schema format has a per-table policies slot — verify policies are recoverable from the fetched head; if not, apps sharing a store must share permissions source, and the API should say so explicitly.
  • Declared-hash source. createDb takes no schema parameter; the client's declared hash appears to come from the process-global last-registered wasm schema (defineNestedApp registers its full deploy target last — correct by construction, but unpinned). Pin this in conformance before provisioning relies on it.
  • Admin-scoped tickets. Whether lofi-node grows a ticket kind that carries provisioning capability, so opt-in doesn't require pasting the raw admin secret (lofi-node side; tracked here for the contract). Resolved: lofi-node ships provision-scoped tickets (lofi-node#2); the gate injects the admin secret itself so it never leaves the node.

Acceptance criteria

  • Store-state classifier with conformance coverage for all four states against a real JazzServer, including the pinned failure surfaces (hang on no_schema, withheld permissions on missing migration, clean denial on uncovered tables).
  • Opt-in provisioning flow: create and update, merging over the stored head schema verbatim, with generated createTables/dropTables/renameTables migrations — validated for the second-app-joins-an-occupied-store scenario end to end.
  • Appslug invariant enforced and conformance-tested: out-of-slug creates/drops/renames/column-ops rejected before publish; sibling tables and policies byte-preserved through a merge; cross-slug rename permitted only within one app's declared namespaces; flat apps restricted to wholly-owned stores.
  • Boot diagnostics surface the store state (no more hanging writes); drift is surfaced with both shapes and never auto-repaired. Status 2026-07-18: the classifier and the ticket-scoped, metadata-only preflight are shipped in package/schema/store.ts (readTicketStoreStatus; all four states; drift is never auto-repaired at the provisioning layer). The open scope is the runtime boot path consuming that preflight — nothing in package/runtime reads it yet, so a schema-less or drifted store still surfaces as a hanging first write rather than a boot diagnostic. This is the last open item on this issue.
  • Docs: provisioning walkthrough (create, second-app merge, drift story), the honesty boundary, and the admin-secret opt-in flow against a lofi-node ticket. (docs/store-provisioning.md; tutorial-voice version at /nodedocs/node/provision-a-store.md, docs/node/sliceable-apps-and-shared-stores.md.)

Depends on #107 Part 1 (shipped in #108: nested namespaces, mangled global names). Complements #107 Part 2 (sink declaration — transport enrollment stays store-mutation-free) and Part 3 (the sink behind the passkey).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions