Skip to content

spiceize #37

Description

@Azerothian

spicedb intergration

  • ormize plugin for queries and mutations
  • offers a central role object that everything else links off
  • gqlize middleware - schema restrictor
  • ormize middleware - access control

spiceize — SpiceDB integration

Expansion of the four bullets in the issue. Grounded in what the code does today; every
claim about current behaviour cites a file.


1. Why this is not a small change

The four bullets sit across two authorization planes that today's code only has one of.

What exists today is a schema-shaping permission model: options.permission is a closed
bag of synchronous boolean predicates (packages/utilize/src/gate.ts), consulted once at
schema build time
(packages/gqlize/src/graphql/index.ts:64-151), whose only effect is which
types/fields/mutations get generated. computeVisibleModels runs a fixpoint over it and folds
the answer back into the bag so every builder agrees
(packages/gqlize/src/graphql/utils/visible-models.ts). An absent predicate means allow
it fails open by design, which is why the bag is closed and unknownPermissionKeys exists.

What SpiceDB provides is object-level authorization: async, per-request, per-row, over a
relationship graph. It cannot be expressed as a synchronous build-time boolean.

So the integration must not try to force one into the other. It splits into two planes that
share one principal:

Plane Question Enforced where SpiceDB call Cacheability
A — schema restriction "may this principal see the Task type / the salary field / the deleteTask mutation at all?" gqlize schema build (per principal), nestize/temporalize gates one CheckBulkPermissions over low-cardinality schema-surface objects very high — keyed by role signature, ~N models × gates
B — object access control "may this principal read this row / write this row?" ormize resolution engine LookupResources (prefilter) or CheckBulkPermissions (postfilter) per query; CheckPermission per mutation target per-request only

The mechanism plane B is built on is a new permission kind: scope, a callback that rewrites
the operation's where (and, on create, forces field values) instead of returning a boolean —
"only rows you own", "only rows in your group". It is worth having on its own, with no SpiceDB
involved, and a LookupResources prefilter is then just one scope provider among several. It is
specified in its own issue #40, and it gates writes as well as reads — a read-only scope is a false sense of
security, since the caller can still update(where: {id: X}) a row it cannot see.

Plane A is a precomputation that produces exactly the Permission bag the codebase already
consumes — so gqlize, nestize, temporalize and ormize-zod4 need no change to their gate logic.
Plane B is new machinery in the ormize engine.

Precedent for the per-request axis already exists: temporalize derives a Permission per call
from context via options.resolvePermission(context)
(packages/temporalize/src/activities.ts:89). gqlize fixes it at createSchema and nestize at
NestizeModule.forRoot — those two are what need the new seam.

temporalize is therefore the cheapest projection to wire and the most dangerous one to wire
carelessly: durable execution adds retries, cross-process boundaries, a sandboxed workflow half
and an unverified caller-supplied context. It gets its own section (§7).


2. Prerequisite: ormize needs a plugin/middleware system (it has none)

Bullets 1 and 4 both say "plugin"/"middleware". Neither exists. What exists:

  • globalHooks / addHook / unshiftHook (packages/ormize/src/manager.ts:150-167) — but the
    hook names are the Sequelize lifecycle list (hookList, manager.ts:21-63) plus afterCount.
    Wrong shape: no query-plan seam, no mutation-authorization seam, no commit seam.
  • definition.before / definition.after (packages/utilize/src/types/index.ts:510-511) — a
    single function per definition, not composable. A plugin claiming it would stomp userland.

Roadmap already records "Middleware/caching options" as unimplemented
(docs/specifications.md §13). So M0 is a generic ormize plugin API, with spiceize as its
first consumer — not a spicedb-shaped hack.

// packages/ormize/src/plugin.ts
export interface OrmizePlugin {
  name: string;
  /** After adapters are registered and definitions created, before sync. */
  setup?(orm: Ormize): void | Promise<void>;
  /** Rewrite a definition at registration (add fields, relationships, comments). */
  onDefinition?(def: Definition): Definition | void;

  query?: {
    /** May merge into `getOptions.where` (via `adapter.mergeFilterStatement`) or set `deny`. */
    beforeFind?(ctx: QueryTap): void | Promise<void>;
    /** May drop rows. Returning a shorter array is honoured; see §6 on `total`. */
    afterFind?(ctx: QueryTap & { rows: AdapterRow[] }): AdapterRow[] | Promise<AdapterRow[]>;
    afterCount?(ctx: QueryTap & { total: number }): number | Promise<number>;
  };

  mutation?: {
    /** Throw to deny. Runs inside the transaction, before the adapter write. */
    before?(ctx: MutationTap): void | Promise<void>;
    /** Runs inside the transaction, after the write, with the resulting rows. */
    after?(ctx: MutationTap & { rows: AdapterRow[] }): void | Promise<void>;
  };

  /** Flush point for external side effects. Runs after ALL adapters committed. */
  afterCommit?(ctx: { orm: Ormize; buffer: unknown[] }): void | Promise<void>;
}

Registration mirrors registerAdapter: orm.use(plugin), chainable, composed with the existing
waterfall util in registration order.

Wiring points (all in packages/ormize/src/manager.ts):

Tap Call site
query.beforeFind resolveFindAll after processListArgsToOptions, before the scope merge (manager.ts:753, merge at manager.ts:770-778) — the same place cross-adapter scoping already merges a where
query.afterFind resolveFindAll after adapter.findAll (manager.ts:798)
query.afterCount both count paths (manager.ts:794, manager.ts:810-819)
query.* (relations) resolveManyRelationship / resolveSingleRelationship (manager.ts:709-751)
mutation.before/after inside mutationEntry's body for processCreate / processUpdate / processDelete / processSelect (manager.ts:962-1061) — already inside the right transaction
afterCommit OrmizeTransaction.commit() (packages/ormize/src/transaction.ts:47) after the commit loop

processSelect matters: it runs relationship mutations against rows found by a where without
a field write
(manager.ts:1024). An authz layer that only taps create/update/delete would
leave a hole there.


3. Bullet 2 — the central role object

"offers a central role object that everything else links off"

Two things need separating, because Zanzibar-style modelling and RBAC pull in opposite directions.

3a. The Principal — request-scoped identity

One resolved-once-per-request object, stored in the existing AsyncLocalStorage store
(packages/ormize/src/context.ts), so all four projections see the same thing without threading:

export interface Principal {
  subject: { type: string; id: string };     // → v1.SubjectReference
  subjectRelation?: string;                  // e.g. "...#member" for group subjects
  roles: string[];                           // coarse roles, drives the schema plane
  /** Stable hash of what affects schema shape. Cache key for plane A. */
  signature: string;
  /** Read-after-write watermark for this request. Updated by every write. */
  zedToken?: string;
  attributes?: Record<string, unknown>;      // → SpiceDB caveat context
}

OrmizeStore gains principal?: Principal alongside transaction and context, and
orm.runWithPrincipal(principal, fn) mirrors the existing runWithContext
(manager.ts:891). Every entry point (yoga context factory, Nest guard, Temporal activity
context) resolves it once.

3b. The Role object — DB-owned, SpiceDB-mirrored

The issue's "central role object that everything else links off" becomes a first-class ormize
definition
shipped by the package, so roles are queryable/mutable through the same GraphQL,
REST and Temporal surfaces as everything else:

Role      { id, name, description }
RoleGrant { id, roleId → Role, subjectType, subjectId, scopeType?, scopeId? }

Source of truth is the database; SpiceDB holds the derived tuples. That direction is
deliberate — the reverse (SpiceDB as truth) means the app cannot list, join or admin roles
without a LookupSubjects fan-out, and it puts the only copy of an audit-relevant record in a
store with no transactional relationship to the rest of the data.

Corresponding .zed:

definition user {}

definition role {
  relation member: user | group#member
  permission assigned = member
}

/** Every protected model gets these, generated (§5). */
definition task {
  relation tenant: tenant
  relation owner: user
  relation viewer: user | role#member
  relation editor: user | role#member

  permission view   = viewer + editor + owner + tenant->view
  permission edit   = editor + owner + tenant->edit
  permission delete = owner + tenant->admin
}

Caveat on RBAC-through-role: routing every grant through role#member recreates RBAC
inside a ReBAC engine and gives up most of what SpiceDB is for (parent->view inheritance,
per-object grants). Recommendation: role carries the coarse, schema-plane grants
("who may see the Salary model at all"); object-plane access uses direct relations on the
resource (viewer, owner, parent). Both are supported; the default generated schema wires
role#member into the resource relations so a pure-RBAC deployment works out of the box.


4. Bullet 3 — gqlize middleware: the schema restrictor

The hard constraint: createSchema builds a GraphQLSchema once, and the permission bag
shapes the type system itself. There is no per-request schema shaping today, and adding an async
per-field check would mean rebuilding types per request — unacceptable.

Design: precompute the bag, cache the schema by signature.

import { createSpiceizeSchemaRestrictor } from "@azerothian/spiceize/gqlize";

const restrictor = await createSpiceizeSchemaRestrictor(orm, {
  client,
  models: { Task: "task", Document: "document" },
  /** Schema-plane objects are low cardinality: N models × ~8 gates. */
  cache: { max: 200, ttlMs: 60_000 },
});

const yoga = createYoga({
  schema: async ({ request }) => {
    const principal = await resolvePrincipal(request);
    return restrictor.schemaFor(principal);   // cached by principal.signature
  },
});

schemaFor does three things:

  1. One bulk check. Every schema-plane question is an object of the form
    gqlize_model:Task#read, gqlize_model:Task#create, gqlize_field:Task.salary#read,
    gqlize_extension:reports#query. These are definitions, not data — the whole set is known
    at boot from orm.defs, so it is one CheckBulkPermissions per distinct principal signature
    (chunked to the server's bulk limit), at one consistency snapshot per the Authzed guidance.
  2. Materialize a Permission bag from the results — a plain object of synchronous closures
    over the resolved map. Nothing downstream can tell it apart from a hand-written bag or one
    from createRoleBasedPermissions.
  3. Build or reuse the schema. LRU keyed by principal.signature. Distinct signatures are
    role-set-shaped, not user-shaped, so a 10k-user deployment typically has single-digit schemas.

Consequences worth stating up front:

  • signature must cover everything that affects shape. Get it wrong and one user gets
    another's schema. It is derived from the sorted role set + a schema-version stamp, and the
    bulk-check result is hashed into the cache entry so a drifted signature is detected rather
    than trusted.
  • Fail closed on SpiceDB unavailability. Contrary to the rest of the permission system,
    which fails open by design (gate.ts isAllowed). schemaFor must throw rather than emit an
    unrestricted schema. Configurable (onUnavailable: "throw" | "lastKnownGood"), default throw.
  • Snapshot artifacts already have the hook. SnapshotOptions.permissionProfile
    (docs/specifications.md:186) is the opaque per-profile id — principal.signature is exactly
    that value, so gqlize build can pre-generate one artifact per role profile and
    materializeSchema picks by signature at boot. Warm start, no build-time SpiceDB dependency.
  • gqlize check (strict SDL diff) becomes the drift test in CI: build per profile, diff.

Same bag feeds the siblings: nestize gains a resolvePermission(req) option to match
temporalize's existing one, and ormize-zod4's generateZodSchemas(orm, { permission }) is called
per profile.


5. Bullet 1 — the ormize plugin: schema mapping, codegen, tuple sync

5a. Declaring the mapping

Per-definition, alongside the existing definition keys:

const TaskDef = defineModel({
  name: "Task",
  define: { name: { type: DataTypes.STRING }, salary: { type: DataTypes.INTEGER } },
  relationships: [{ type: "belongsTo", model: "Project", options: { foreignKey: "projectId" } }],
  spicedb: {
    objectType: "task",
    /** FK → SpiceDB relation. Written and rewritten by the sync plugin. */
    relations: { project: { from: "projectId", type: "project" },
                 owner:   { from: "ownerId",   type: "user" } },
    permissions: { read: "view", create: "create", update: "edit", delete: "delete" },
    /** How list queries are filtered (§6). */
    filterStrategy: "prefilter",
    /** Schema-plane gates this model answers with (defaults to `permissions`). */
    schemaPlane: { model: "view", field: { salary: "view_salary" } },
  },
});

Definition is an open object type, so this needs one added optional key in
packages/utilize/src/types/index.ts:487 plus a SpicedbModelConfig type. No breaking change.

5b. Codegen + drift check

Deliberately mirrors the existing gqlize CLI idiom (build / check / print):

  • spiceize print — emit the .zed schema derived from orm.defs + the role objects (§3b).
  • spiceize checkReadSchema from the server, diff against generated, non-zero exit on
    drift
    . This is the CI gate; it is the SpiceDB analogue of gqlize check.
  • spiceize applyWriteSchema, guarded behind an explicit flag; refuses when the diff
    removes a definition or relation unless --allow-destructive.

Hand-written .zed fragments are merged, not overwritten — generated definitions carry a marker
comment and everything outside them is preserved verbatim.

5c. Tuple sync (dual writes) — the actual risk in this project

On create: write the relation tuples derived from spicedb.relations. On update: if a mapped FK
changed, DELETE the old tuple and TOUCH the new. On delete: DeleteRelationships for the
object (and its subject-side tuples).

The correctness problem: ormize's transaction is explicitly best-effort, not 2PC — commit
across adapters can partially fail and is documented as unrecoverable
(packages/ormize/src/transaction.ts:7-16). Adding SpiceDB as a third participant makes that
worse, not better. Two supported modes:

  1. sync: "direct" (default, dev/simple prod). Tuple writes buffer on the transaction and
    flush in afterCommit, with bounded retry. A flush failure after DB commit logs loudly and
    raises a metric; the reconciler (below) repairs it. Honest about the window; matches the
    project's existing posture rather than pretending otherwise.
  2. sync: "outbox" (recommended for production). The plugin's onDefinition registers a
    SpicedbOutbox model; tuple mutations are written inside the same DB transaction as the
    data. A drainer flushes to SpiceDB and records the returned ZedToken. Then the tuple write is
    exactly as durable as the data write. There is a clean tie-in here: the drainer is naturally a
    @azerothian/temporalize activity + workflow, which this monorepo already ships — see §7b for
    why its conventions fit, and what the Watch reconciler needs that the default activity shape
    does not give.

Plus a reconciler: SpiceDB's Watch API stream against the outbox/DB state to detect and
report divergence (report first; auto-repair opt-in).

ZedToken threading. WriteRelationships returns written_at; store it on principal.zedToken
and on the row (an optional generated spicedbZedToken column via onDefinition) so subsequent
checks use consistency: { at_least_as_fresh } — the documented read-after-write pattern. Default
consistency for ordinary reads is minimize_latency; configurable per call site.


6. Bullet 4 — the ormize middleware: object-level access control

Reads

Two strategies, per model, because the Authzed guidance itself is size-dependent
(LookupResources is recommended under ~10k accessible resources; CheckBulk is preferred
where the candidate list is bounded):

  • prefilterLookupResources(task#view, subject) → id set → a scope filter
    ({ id: { in: [...] } }, §8 / scope: a permission kind that rewrites the query (row-level scoping for reads and writes) #40) merged into the query via
    the existing adapter.mergeFilterStatement(pk, ids, true, where)
    (packages/utilize/src/types/index.ts:178; both adapters implement it — sequelize
    index.ts:960, valkey index.ts:556, and the comment at cross-adapter.ts:91 notes it turns
    a list into an in). Cursor pagination, ordering and total all stay correct.
  • postfilter — fetch the page, then CheckBulkPermissions over the fetched ids, drop the
    denied. Bounded cost, but see the count problem below.
  • none — public model, no object-plane check.

The total problem is the single biggest gotcha. The connection shape is
{ pageInfo, total, edges } and total is backed on supported dialects by an inline
COUNT(*) OVER() (hasInlineCountFeature / getInlineCount, spec §6, manager.ts:810-819).
Post-filtering rows after that count makes total a lie and breaks page-size invariants.
The proposal takes this as a design constraint rather than a bug to find later:

  • prefilter is the default, because the id-set lands in both getOptions.where and
    countOptions.where and the count stays exact.
  • postfilter must declare its count semantics: countMode: "exact" | "candidate" | "null"
    exact re-checks the full candidate set (documented as expensive), candidate returns the
    pre-authz count (documented as an upper bound, leaks cardinality), null returns nothing and
    requires the connection's total to be nullable. Default candidate is not acceptable where
    cardinality is sensitive; the config must be explicit, and spiceize check warns when a model
    combines postfilter with a non-null count mode.
  • Page-fill (over-fetch and loop, per the Authzed list-endpoint pattern) is offered as
    postfilter + pageFill: true, and is incompatible with total.

Relationships

Nested connections resolve through resolveManyRelationship, which for JOIN-eager relations
never issues a child query at all (spec §8: gqlize fires the child's find hooks manually).
Three options, in order of preference:

  1. Model inheritance in SpiceDB (permission view = viewer + parent->view) and check only at
    the root — the Zanzibar-idiomatic answer, zero extra checks, and the reason to use SpiceDB
    rather than row filters in the first place. Declared as inherit: "parent" on the relation.
  2. Force separate: true for authz-filtered relations so the child gets its own query and
    the prefilter path applies unchanged.
  3. Post-filter eager rows in applyEagerAfterFind's neighbourhood — correct but N+1-prone,
    and total on the nested connection has the same problem as above.

Default: (1) where declared, (2) otherwise, with (3) available behind a flag.

Writes

Write authorization has two layers: the scope filter of #40 §4, AND-ed into the mutation's where
so an out-of-scope row is unreachable by id, and the explicit checks below for the cases a filter
cannot express (a create has no where; a per-object edit permission is not a column).

mutation.before runs inside the transaction:

  • update/deleteCheckPermission(objectType:id, edit|delete, subject) for every targeted
    row. Since ormize's update resolves rows via a where
    (processUpdateadapter.getUpdateFunction, manager.ts:985-988), the check happens on the
    resolved set, using CheckBulkPermissions. Unscoped mutations are already guarded elsewhere
    (assertScopedMutation, packages/utilize/src/guards.ts:48) — reuse it.
  • create — there is no object to check yet, so the check is on the container:
    CheckPermission(project:<projectId>, create_task, subject), derived from spicedb.relations.
    Where no container exists, fall back to the schema plane (gqlize_model:Task#create).
  • processSelect — treated as a read for the found rows and a write for whatever
    relationship mutations it applies (manager.ts:1024).
  • nested relationship mutationsapplyRelationshipMutations
    (packages/ormize/src/relationship-mutations.ts) is the deep-mutation verb table; each verb
    that creates/links/unlinks needs the same gate, or nested mutations become the bypass.

Batching and caching

  • Per-request memo of (objectType, id, permission) → decision, in the ambient store, cleared
    per request. Removes the duplicate-check N+1 that GraphQL nesting guarantees.
  • Coalesce checks raised in the same tick into one CheckBulkPermissions (DataLoader-shaped,
    no dependency needed — the pattern is ~40 lines).
  • All checks in one request pinned to one consistency snapshot (at_least_as_fresh on the
    request's ZedToken), so a nested query cannot see two different authorization worlds.

7. temporalize — the durable-execution projection

temporalize is the projection with the most existing seam and the most new hazards, so it
gets its own section rather than a subpath footnote.

What it already has, and nothing else does: a per-call permission axis.
options.resolvePermission(context) is resolved on every activity invocation
(packages/temporalize/src/activities.ts:89) and is already Promise-returning, so plane A drops
in with no interface change:

import { createWorkers } from "@azerothian/temporalize";
import { createSpiceizePermissionResolver } from "@azerothian/spiceize/temporalize";

const workers = await createWorkers(orm, {
  queuePrefix: "myapp",
  resolvePermission: createSpiceizePermissionResolver({ client, principalFrom, cache }),
});

Everything downstream then follows for free: isModelAllowed gates the activity
(activities.ts:90), assertFilterAllowed / assertOrderAllowed / assertMutationAllowed gate
the arguments, and registry.schemas(permission) re-derives the ormize-zod4 input-validation
schemas
under the same bag (packages/temporalize/src/registry.ts:49-61). One bulk check
therefore restricts the GraphQL SDL, the OpenAPI document and Temporal's input validation
identically — which is the strongest argument for materializing a Permission bag (§4) rather
than checking inline.

7a. Five Temporal-specific hazards

1. resolvePermission must return a stable object per role, not a fresh one.
TemporalizeRegistry memoizes the zod SchemaSet in a WeakMap keyed by permission object
identity
(registry.ts:26, and the class doc says so explicitly). A resolver that builds a new
bag per call silently re-runs generateZodSchemas for every activity. The plane A cache is
already keyed by principal.signature, so the fix is to return the cached bag instance
but it has to be stated, because the natural implementation gets it wrong and the symptom is a
throughput regression, not an error.

2. context is untrusted, JSON-shaped, caller-supplied — and today that is fine, but under
spiceize it is an authorization bypass.

requireContext checks only that context is an object and rejects transaction
(packages/temporalize/src/guards.ts:38-57); the README's own example is { userId: "u1", role: "admin" }.
Anything that can start a workflow or reach the task queue can therefore claim to be an admin.
That is a defensible trust model when context merely feeds definition.before hooks — it is not
one when it selects the SpiceDB subject. Requirement:

type PrincipalFrom = (context: CallerContext) => Promise<Principal>;

principalFrom must verify rather than read — a signed assertion (JWT/PASETO) carried in
context.token, verified in the activity. Raw { userId } contexts are dev-only, and
createSpiceizePermissionResolver warns loudly at startup when configured without a verifier.
Note the client half makes this unavoidable: createTemporalizeClient accepts a plain queue map
and needs no ormize instance and no database (packages/temporalize/src/client.ts:84-89), so
the principal cannot be resolved there — it must be minted upstream and carried as a token.

3. Workflows cannot check permissions; only activities can.
The workflow half is bundled into an isolated V8 sandbox and must stay free of ormize, Node
built-ins and side effects (packages/temporalize/src/workflows.ts:1-7). No gRPC, no SpiceDB, no
IO. Consequences: every authorization decision happens inside an activity; a workflow that
wants to branch on a permission must call a dedicated spiceize.check activity; and a decision
taken at workflow start must not be cached in workflow state and reused hours later. Because
invoke resolves the permission per activity call, the default behaviour is already correct —
the design note is "do not optimize this away".

4. Activities retry, so every tuple write must be idempotent.
Temporal is at-least-once. A mutating activity runs inside orm.transaction() when
transactional !== false (activities.ts:98), so the §5c afterCommit flush runs per attempt
a retried activity re-flushes its buffer. RelationshipUpdate_Operation.CREATE fails on an
existing tuple; TOUCH does not. So the sync plugin uses TOUCH for writes and precondition-free
DELETE for removals
, unconditionally, not as an optimization. Same reasoning applies to the
outbox drainer.

5. ZedTokens do not survive an activity boundary.
The read-after-write watermark lives in the ambient AsyncLocalStorage store, which is
per-process. A workflow that creates a row in one activity and lists it in the next may land
on a different worker, with no store and no token — so the read silently runs at
minimize_latency against a stale snapshot and the row it just wrote is invisible. This is a
correctness bug that only exists in the Temporal projection.

Fix: make the token part of the wire contract. ActivityRequest<T> gains an optional
zedToken?: string alongside context, and mutating activity results carry the written_at
token back. Both belong in packages/temporalize/src/workflow-types.ts — it is import-free by
construction and is already the module holding the contract both halves share
(workflow-types.ts:83, :166). The generic CRUD workflows in workflows.ts thread it: capture
from a create/update/destroy result, pass into the next call. Userland workflows opt in the same
way. createSpiceizePermissionResolver reads req.zedToken and installs it on the principal, so
subsequent checks in that activity use at_least_as_fresh.

7b. temporalize as the outbox drainer and reconciler

§5c recommends outbox mode for production. The reason it is the right recommendation in this
monorepo specifically
is that the durable-execution infrastructure is already a shipped package,
and its conventions fit:

  • A queue maps to a model, so the plugin's generated SpicedbOutbox model gets its own task
    queue and its own worker
    , scaling independently of the API workers — which is exactly what a
    drainer wants.
  • Retry, backoff and failure visibility come from Temporal rather than a hand-rolled loop.
  • A schedule (or a cron workflow) drives SpicedbOutbox.drain — batch-read pending rows,
    WriteRelationships, record the returned ZedToken, mark drained. Idempotent by hazard 4.

The reconciler is the interesting one, because it does not fit the default activity shape.
SpiceDB's Watch is a long-lived gRPC stream; DEFAULT_ACTIVITY_OPTIONS is a one-minute
startToCloseTimeout (packages/temporalize/src/workflows.ts:40-42). So the Watch consumer is a
heartbeating activity with a heartbeatTimeout, checkpointing the last-seen ZedToken as
heartbeat details so a restarted attempt resumes from that revision rather than replaying the
world, wrapped in a long-running workflow that continues-as-new on a bounded interval. Divergence
is reported (metric + workflow failure), and auto-repair stays opt-in.

7c. Per-worker scoping

A worker only hosts the models on its queue (createWorkers builds one worker per queue,
packages/temporalize/src/worker.ts), and createActivities already accepts a models narrowing
argument (activities.ts:68-71). So the schema-plane bulk check for a given worker only needs
that worker's models, not the whole definition set — a smaller check and a smaller cache per
process. Free, and worth wiring at M5 rather than retrofitting.

7d. What is out of scope

Signals, queries, child workflows and continueAsNew carry their own inputs and are userland
surface — temporalize does not generate them, so spiceize does not gate them. Documented
explicitly: a signal handler that mutates data must re-resolve the principal, exactly as an
activity does, or it is an unguarded write path.

7e. Testing

TestWorkflowEnvironment (time-skipping) against spicedb serve-testing, sharing the M1 harness.
The tests that matter are the hazards above, not the happy path: a retried mutating activity must
not double-write tuples; a create-then-read workflow across two workers must see its own write;
a forged { userId: "admin" } context with no valid token must be rejected; a revoked role must
stop passing within the cache TTL.


8. scope — a permission that rewrites the query — split out to #40

The mechanism plane B needs is a new permission kind: scope, a predicate that returns a
portable filter instead of a boolean, AND-ed into the operation's where for reads and
writes alike.

It was specified in full here originally and now lives in its own issue, #40, because it
ships without SpiceDB and is useful on its own: "only rows you own", "only rows in a group you
are in" is a common requirement with no answer in the current permission bag. That issue covers
the type, the fail-closed/async/isAllowed hazards, the portable-filter merge, the four engine
chokepoints (resolveFindAll, processUpdate, processDelete, processSelect), set for
create, nested relationship mutations, the createRoleBasedPermissions sugar, and the bypass
test matrix.

What this proposal needs from it: LookupResources returns an id set, and an id set is a
portable filter — { id: { in: [...] } }. So the SpiceDB prefilter of §6 is a scope provider,
not a parallel mechanism. Scope sources AND together, so a role-based scope and a SpiceDB scope
compose without precedence rules. #40 is therefore a hard dependency of M4 here, and the only
part of it this proposal adds is the provider.



9. Package layout

packages/spiceize/                     @azerothian/spiceize
  src/index.ts                         client wrapper, Principal, consistency + batching
  src/plugin.ts                        OrmizePlugin: tuple sync + access control
  src/schema/                          .zed generation, diff, merge
  src/cli/                             print / check / apply
  src/gqlize.ts        → subpath       @azerothian/spiceize/gqlize     (schema restrictor)
  src/nestize.ts       → subpath       @azerothian/spiceize/nestize    (guard + resolvePermission)
  src/temporalize.ts   → subpath       @azerothian/spiceize/temporalize
                                         (permission resolver, principalFrom, ZedToken threading)
  src/temporal-workflows.ts → subpath  @azerothian/spiceize/temporal-workflows
                                         (outbox drainer + Watch reconciler — sandbox-safe, §7b)

temporal-workflows is a separate subpath on purpose: the Temporal workflow bundle runs in an
isolated V8 sandbox and may not reach ormize, gRPC or Node built-ins
(packages/temporalize/src/workflows.ts:1-7), so it cannot share a module with the client wrapper.
Same split temporalize already maintains between workflows.ts and workflow-types.ts.

Dependency discipline (the repo's stated invariant is an acyclic graph with a GraphQL-free core):
spiceize core depends only on utilize + ormize + @authzed/authzed-node and stays
GraphQL-free. gqlize/nestize/temporalize are optional peer deps reached only through
subpath exports, exactly as graphql-types and the adapters are wired. Graph becomes:

graphql-types + utilize → ormize → { gqlize, ormize-zod4, nestize, temporalize, spiceize }
                                              ↖ spiceize/gqlize (peer) ┘

The ormize plugin API itself lands in ormize (§2), not in spiceize.


10. Milestones

# Deliverable Acceptance
M0 ormize plugin/middleware API + all taps wired Unit tests per tap; a no-op plugin changes nothing; two plugins compose in registration order; closes the "Middleware/caching options" item in spec §13
M0.5 #40 — the scope permission key. Tracked and specified separately; listed here because M4 cannot land without it Per #40's own acceptance (its bypass matrix). No SpiceDB involvement — it can ship long before anything else here
M1 @azerothian/spiceize core: client, Principal, consistency policy, per-request memo + bulk coalescing; test harness on spicedb serve-testing Jest suite green against an ephemeral SpiceDB; per-test datastore isolation via distinct bearer tokens (parallel-safe, mirroring how redis-memory-server is pinned for the valkey adapter)
M2 Schema mapping + spiceize print/check/apply Generated .zed round-trips through WriteSchema/ReadSchema; check exits non-zero on drift; hand-written fragments preserved
M3 Tuple sync plugin: direct + outbox modes, idempotent TOUCH/DELETE writes, ZedToken capture; drainer + Watch reconciler shipped as temporalize workflows (§7b) Rolled-back DB tx writes no tuples; committed tx's tuples observable; killed process mid-flush repaired by the reconciler; a re-run drain is a no-op
M4 Object-level access control as a scope provider (§8, on top of #40): prefilter/postfilter/none, count semantics, relationship policy, mutation gates incl. nested relationship mutations Denied rows absent from list, nested connection, node(id:) and relay id fetcher; total exact under prefilter; nested-mutation bypass test
M5 Plane A: gqlize schema restrictor + LRU, permissionProfile artifact wiring, nestize resolvePermission, zod4 per-profile Two principals get structurally different SDL; gqlize check clean per profile; SpiceDB down ⇒ throws, never an open schema
M6 temporalize (§7): createSpiceizePermissionResolver, verifying principalFrom, zedToken on the wire contract + threading in the generic CRUD workflows, per-worker model scoping Retried mutating activity writes no duplicate tuples; create-then-read across two workers sees its own write; forged { userId: "admin" } with no valid token is rejected; revoked role stops passing within the TTL; stable bag identity keeps registry.schemas memoized
M7 examples/spiceize-basic + examples/spiceize-temporal, docs (guide + spec §7/§13 updates), CI service containers (SpiceDB, Temporal) Examples run from repo root like the other five; spec no longer contradicts the code

M0, M0.5 (#40) and M1 are independent and can run in parallel — #40 is the one piece that delivers
standalone user value with no SpiceDB deployment, so it is the natural first merge.
M4 depends on M0+M0.5+M1+M3; M5 depends on M1 only;
M6 depends on M1 (resolver) and M3 (drainer), and its ZedToken-threading half is independent of both.


11. Risks and open questions

  1. Latency. Every list query gains a network round trip. Budget it explicitly, and measure
    prefilter vs postfilter on a representative dataset before defaulting.
  2. LookupResources blowup. Fine under ~10k accessible resources, degrades after. Need a
    configured ceiling that falls back to postfilter + page-fill rather than materializing a
    100k-element IN clause.
  3. Dual-write divergence — mitigated by outbox mode, but direct mode's window must be
    documented rather than glossed.
  4. Fail-open vs fail-closed asymmetry. The existing gate fails open by design; the SpiceDB
    layer must fail closed. Two opposite defaults in one system is a real footgun — worth a
    prominent doc note and a startup warning when both are configured.
  5. Schema-plane cardinality. N models × gates is small, but a wide field-level plane
    (gqlize_field:*) grows it. Cap it: field-plane objects only for fields that declare one.
  6. Cache invalidation on role change. A revoked role must not keep serving a cached schema
    for the TTL. Options: short TTL, explicit restrictor.invalidate(signature) called from the
    Role/RoleGrant mutation path (the plugin can wire this itself), or Watch-driven eviction.
  7. total semantics (§6) — needs a decision before M4 starts, since it is user-visible API.
  8. Does role mediate object-plane grants, or only schema-plane? §3b recommends the hybrid;
    a pure-RBAC deployment is simpler but gives up inheritance. Worth confirming the intended
    deployment shape first — it changes the generated .zed materially.
  9. Caveats. SpiceDB caveats (attribute-based conditions) map onto principal.attributes;
    in scope as a pass-through, out of scope for codegen in M2.
  10. Forged Temporal contexts (§7a.2) — the highest-severity item here. temporalize's
    context is caller-supplied and unverified, and the README teaches { userId, role }.
    Under spiceize that becomes subject spoofing. Needs a verifying principalFrom and a
    documented breaking-ish expectation for existing temporalize users, who are currently
    allowed to pass whatever they like.
  11. ActivityRequest gains a field (§7a.5). Adding optional zedToken is source-compatible,
    but the generic CRUD workflows start threading it, which changes observable behaviour for
    anyone already using them. Decide whether that is opt-in (spiceize: true worker option) or
    default-on before M6.
  12. The scope risks are now scope: a permission kind that rewrites the query (row-level scoping for reads and writes) #40's — an async, non-boolean permission key against gate.ts's
    documented synchronous-and-coerced invariant, and the silent-empty vs explicit-denial choice
    for a scoped-out mutation. Both are decided there, and both are decided before M4 consumes
    the key here.
  13. Scope cost per request. A SpiceDB LookupResources on every request, per model, is a
    latency regression on top of whatever the role-based scope already costs. The per-request
    memo (scope: a permission kind that rewrites the query (row-level scoping for reads and writes) #40 §5) is not optional, and this provider does IO on every call — it needs measuring
    before it ships.
  14. Watch as a heartbeating activity. Long-lived gRPC streams inside Temporal activities are
    a known-awkward shape. If it proves fragile, the fallback is a plain long-running process
    outside Temporal — worth prototyping early in M3 rather than discovering at M6.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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