You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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?"
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.tsexportinterfaceOrmizePlugin{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)
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 wherewithout
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:
exportinterfacePrincipal{subject: {type: string;id: string};// → v1.SubjectReferencesubjectRelation?: string;// e.g. "...#member" for group subjectsroles: 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:
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:
definitionuser {}
definitionrole {
relationmember: user | group#memberpermissionassigned = member
}
/** Every protected model gets these, generated (§5). */definitiontask {
relationtenant: tenantrelationowner: userrelationviewer: user | role#memberrelationeditor: user | role#memberpermissionview = viewer+editor+owner+tenant->viewpermissionedit = editor+owner+tenant->editpermissiondelete = 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 GraphQLSchemaonce, 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";constrestrictor=awaitcreateSpiceizeSchemaRestrictor(orm,{
client,models: {Task: "task",Document: "document"},/** Schema-plane objects are low cardinality: N models × ~8 gates. */cache: {max: 200,ttlMs: 60_000},});constyoga=createYoga({schema: async({ request })=>{constprincipal=awaitresolvePrincipal(request);returnrestrictor.schemaFor(principal);// cached by principal.signature},});
schemaFor does three things:
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.
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.
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.tsisAllowed). 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.
Per-definition, alongside the existing definition keys:
constTaskDef=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.
spiceize print — emit the .zed schema derived from orm.defs + the role objects (§3b).
spiceize check — ReadSchema 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 apply — WriteSchema, 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:
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.
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):
prefilter — LookupResources(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:
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.
Force separate: true for authz-filtered relations so the child gets its own query and
the prefilter path applies unchanged.
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/delete — CheckPermission(objectType:id, edit|delete, subject) for every targeted
row. Since ormize's update resolves rows via a where
(processUpdate → adapter.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 mutations — applyRelationshipMutations
(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:
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:
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.
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:
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
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
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
Latency. Every list query gains a network round trip. Budget it explicitly, and measure
prefilter vs postfilter on a representative dataset before defaulting.
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.
Dual-write divergence — mitigated by outbox mode, but direct mode's window must be
documented rather than glossed.
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.
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.
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.
total semantics (§6) — needs a decision before M4 starts, since it is user-visible API.
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.
Caveats. SpiceDB caveats (attribute-based conditions) map onto principal.attributes;
in scope as a pass-through, out of scope for codegen in M2.
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.
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.
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.
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.
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.
spicedb intergration
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.permissionis a closedbag of synchronous boolean predicates (
packages/utilize/src/gate.ts), consulted once atschema build time (
packages/gqlize/src/graphql/index.ts:64-151), whose only effect is whichtypes/fields/mutations get generated.
computeVisibleModelsruns a fixpoint over it and foldsthe 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
unknownPermissionKeysexists.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:
Tasktype / thesalaryfield / thedeleteTaskmutation at all?"CheckBulkPermissionsover low-cardinality schema-surface objectsLookupResources(prefilter) orCheckBulkPermissions(postfilter) per query;CheckPermissionper mutation targetThe mechanism plane B is built on is a new permission kind:
scope, a callback that rewritesthe 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
LookupResourcesprefilter is then just onescopeprovider among several. It isspecified 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
Permissionbag the codebase alreadyconsumes — 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
Permissionper callfrom context via
options.resolvePermission(context)(
packages/temporalize/src/activities.ts:89). gqlize fixes it atcreateSchemaand nestize atNestizeModule.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 thehook names are the Sequelize lifecycle list (
hookList, manager.ts:21-63) plusafterCount.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) — asingle 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 itsfirst consumer — not a spicedb-shaped hack.
Registration mirrors
registerAdapter:orm.use(plugin), chainable, composed with the existingwaterfallutil in registration order.Wiring points (all in
packages/ormize/src/manager.ts):query.beforeFindresolveFindAllafterprocessListArgsToOptions, before thescopemerge (manager.ts:753, merge atmanager.ts:770-778) — the same place cross-adapter scoping already merges awherequery.afterFindresolveFindAllafteradapter.findAll(manager.ts:798)query.afterCountmanager.ts:794,manager.ts:810-819)query.*(relations)resolveManyRelationship/resolveSingleRelationship(manager.ts:709-751)mutation.before/aftermutationEntry's body forprocessCreate/processUpdate/processDelete/processSelect(manager.ts:962-1061) — already inside the right transactionafterCommitOrmizeTransaction.commit()(packages/ormize/src/transaction.ts:47) after the commit loopprocessSelectmatters: it runs relationship mutations against rows found by awherewithouta field write (
manager.ts:1024). An authz layer that only taps create/update/delete wouldleave a hole there.
3. Bullet 2 — the central role object
Two things need separating, because Zanzibar-style modelling and RBAC pull in opposite directions.
3a. The
Principal— request-scoped identityOne 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:OrmizeStoregainsprincipal?: Principalalongsidetransactionandcontext, andorm.runWithPrincipal(principal, fn)mirrors the existingrunWithContext(
manager.ts:891). Every entry point (yoga context factory, Nest guard, Temporal activitycontext) resolves it once.3b. The
Roleobject — DB-owned, SpiceDB-mirroredThe 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:
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
LookupSubjectsfan-out, and it puts the only copy of an audit-relevant record in astore with no transactional relationship to the rest of the data.
Corresponding
.zed:Caveat on RBAC-through-
role: routing every grant throughrole#memberrecreates RBACinside a ReBAC engine and gives up most of what SpiceDB is for (
parent->viewinheritance,per-object grants). Recommendation:
rolecarries the coarse, schema-plane grants("who may see the
Salarymodel at all"); object-plane access uses direct relations on theresource (
viewer,owner,parent). Both are supported; the default generated schema wiresrole#memberinto the resource relations so a pure-RBAC deployment works out of the box.4. Bullet 3 — gqlize middleware: the schema restrictor
The hard constraint:
createSchemabuilds aGraphQLSchemaonce, and the permission bagshapes 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.
schemaFordoes three things: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 knownat boot from
orm.defs, so it is oneCheckBulkPermissionsper distinct principal signature(chunked to the server's bulk limit), at one consistency snapshot per the Authzed guidance.
Permissionbag from the results — a plain object of synchronous closuresover the resolved map. Nothing downstream can tell it apart from a hand-written bag or one
from
createRoleBasedPermissions.principal.signature. Distinct signatures arerole-set-shaped, not user-shaped, so a 10k-user deployment typically has single-digit schemas.
Consequences worth stating up front:
signaturemust cover everything that affects shape. Get it wrong and one user getsanother'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.
which fails open by design (
gate.tsisAllowed).schemaFormust throw rather than emit anunrestricted schema. Configurable (
onUnavailable: "throw" | "lastKnownGood"), defaultthrow.SnapshotOptions.permissionProfile(
docs/specifications.md:186) is the opaque per-profile id —principal.signatureis exactlythat value, so
gqlize buildcan pre-generate one artifact per role profile andmaterializeSchemapicks 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 matchtemporalize's existing one, and ormize-zod4's
generateZodSchemas(orm, { permission })is calledper profile.
5. Bullet 1 — the ormize plugin: schema mapping, codegen, tuple sync
5a. Declaring the mapping
Per-definition, alongside the existing definition keys:
Definitionis an open object type, so this needs one added optional key inpackages/utilize/src/types/index.ts:487plus aSpicedbModelConfigtype. No breaking change.5b. Codegen + drift check
Deliberately mirrors the existing
gqlizeCLI idiom (build/check/print):spiceize print— emit the.zedschema derived fromorm.defs+ the role objects (§3b).spiceize check—ReadSchemafrom the server, diff against generated, non-zero exit ondrift. This is the CI gate; it is the SpiceDB analogue of
gqlize check.spiceize apply—WriteSchema, guarded behind an explicit flag; refuses when the diffremoves a definition or relation unless
--allow-destructive.Hand-written
.zedfragments are merged, not overwritten — generated definitions carry a markercomment 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 FKchanged,
DELETEthe old tuple andTOUCHthe new. On delete:DeleteRelationshipsfor theobject (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 thatworse, not better. Two supported modes:
sync: "direct"(default, dev/simple prod). Tuple writes buffer on the transaction andflush in
afterCommit, with bounded retry. A flush failure after DB commit logs loudly andraises a metric; the reconciler (below) repairs it. Honest about the window; matches the
project's existing posture rather than pretending otherwise.
sync: "outbox"(recommended for production). The plugin'sonDefinitionregisters aSpicedbOutboxmodel; tuple mutations are written inside the same DB transaction as thedata. 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/temporalizeactivity + workflow, which this monorepo already ships — see §7b forwhy its conventions fit, and what the Watch reconciler needs that the default activity shape
does not give.
Plus a reconciler: SpiceDB's
WatchAPI stream against the outbox/DB state to detect andreport divergence (report first; auto-repair opt-in).
ZedToken threading.
WriteRelationshipsreturnswritten_at; store it onprincipal.zedTokenand on the row (an optional generated
spicedbZedTokencolumn viaonDefinition) so subsequentchecks use
consistency: { at_least_as_fresh }— the documented read-after-write pattern. Defaultconsistency 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
(
LookupResourcesis recommended under ~10k accessible resources;CheckBulkis preferredwhere the candidate list is bounded):
prefilter—LookupResources(task#view, subject)→ id set → ascopefilter(
{ id: { in: [...] } }, §8 / scope: a permission kind that rewrites the query (row-level scoping for reads and writes) #40) merged into the query viathe existing
adapter.mergeFilterStatement(pk, ids, true, where)(
packages/utilize/src/types/index.ts:178; both adapters implement it — sequelizeindex.ts:960, valkeyindex.ts:556, and the comment atcross-adapter.ts:91notes it turnsa list into an
in). Cursor pagination, ordering andtotalall stay correct.postfilter— fetch the page, thenCheckBulkPermissionsover the fetched ids, drop thedenied. Bounded cost, but see the count problem below.
none— public model, no object-plane check.The
totalproblem is the single biggest gotcha. The connection shape is{ pageInfo, total, edges }andtotalis backed on supported dialects by an inlineCOUNT(*) OVER()(hasInlineCountFeature/getInlineCount, spec §6,manager.ts:810-819).Post-filtering rows after that count makes
totala lie and breaks page-size invariants.The proposal takes this as a design constraint rather than a bug to find later:
prefilteris the default, because the id-set lands in bothgetOptions.whereandcountOptions.whereand the count stays exact.postfiltermust declare its count semantics:countMode: "exact" | "candidate" | "null"—exactre-checks the full candidate set (documented as expensive),candidatereturns thepre-authz count (documented as an upper bound, leaks cardinality),
nullreturns nothing andrequires the connection's
totalto be nullable. Defaultcandidateis not acceptable wherecardinality is sensitive; the config must be explicit, and
spiceize checkwarns when a modelcombines
postfilterwith a non-nullcount mode.postfilter+pageFill: true, and is incompatible withtotal.Relationships
Nested connections resolve through
resolveManyRelationship, which for JOIN-eager relationsnever issues a child query at all (spec §8: gqlize fires the child's find hooks manually).
Three options, in order of preference:
permission view = viewer + parent->view) and check only atthe 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.separate: truefor authz-filtered relations so the child gets its own query andthe prefilter path applies unchanged.
applyEagerAfterFind's neighbourhood — correct but N+1-prone,and
totalon 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
scopefilter of #40 §4, AND-ed into the mutation'swhereso 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-objecteditpermission is not a column).mutation.beforeruns inside the transaction:CheckPermission(objectType:id, edit|delete, subject)for every targetedrow. Since ormize's update resolves rows via a
where(
processUpdate→adapter.getUpdateFunction,manager.ts:985-988), the check happens on theresolved set, using
CheckBulkPermissions. Unscoped mutations are already guarded elsewhere(
assertScopedMutation,packages/utilize/src/guards.ts:48) — reuse it.CheckPermission(project:<projectId>, create_task, subject), derived fromspicedb.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 whateverrelationship mutations it applies (
manager.ts:1024).applyRelationshipMutations(
packages/ormize/src/relationship-mutations.ts) is the deep-mutation verb table; each verbthat creates/links/unlinks needs the same gate, or nested mutations become the bypass.
Batching and caching
(objectType, id, permission)→ decision, in the ambient store, clearedper request. Removes the duplicate-check N+1 that GraphQL nesting guarantees.
CheckBulkPermissions(DataLoader-shaped,no dependency needed — the pattern is ~40 lines).
at_least_as_freshon therequest'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 alreadyPromise-returning, so plane A dropsin with no interface change:
Everything downstream then follows for free:
isModelAllowedgates the activity(
activities.ts:90),assertFilterAllowed/assertOrderAllowed/assertMutationAllowedgatethe arguments, and
registry.schemas(permission)re-derives the ormize-zod4 input-validationschemas under the same bag (
packages/temporalize/src/registry.ts:49-61). One bulk checktherefore restricts the GraphQL SDL, the OpenAPI document and Temporal's input validation
identically — which is the strongest argument for materializing a
Permissionbag (§4) ratherthan checking inline.
7a. Five Temporal-specific hazards
1.
resolvePermissionmust return a stable object per role, not a fresh one.TemporalizeRegistrymemoizes the zodSchemaSetin aWeakMapkeyed by permission objectidentity (
registry.ts:26, and the class doc says so explicitly). A resolver that builds a newbag per call silently re-runs
generateZodSchemasfor every activity. The plane A cache isalready 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.
contextis untrusted, JSON-shaped, caller-supplied — and today that is fine, but underspiceize it is an authorization bypass.
requireContextchecks only thatcontextis an object and rejectstransaction(
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
contextmerely feedsdefinition.beforehooks — it is notone when it selects the SpiceDB subject. Requirement:
principalFrommust verify rather than read — a signed assertion (JWT/PASETO) carried incontext.token, verified in the activity. Raw{ userId }contexts are dev-only, andcreateSpiceizePermissionResolverwarns loudly at startup when configured without a verifier.Note the client half makes this unavoidable:
createTemporalizeClientaccepts a plain queue mapand needs no ormize instance and no database (
packages/temporalize/src/client.ts:84-89), sothe 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, noIO. Consequences: every authorization decision happens inside an activity; a workflow that
wants to branch on a permission must call a dedicated
spiceize.checkactivity; and a decisiontaken at workflow start must not be cached in workflow state and reused hours later. Because
invokeresolves 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()whentransactional !== false(activities.ts:98), so the §5cafterCommitflush runs per attempt —a retried activity re-flushes its buffer.
RelationshipUpdate_Operation.CREATEfails on anexisting tuple;
TOUCHdoes not. So the sync plugin uses TOUCH for writes and precondition-freeDELETE 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_latencyagainst a stale snapshot and the row it just wrote is invisible. This is acorrectness bug that only exists in the Temporal projection.
Fix: make the token part of the wire contract.
ActivityRequest<T>gains an optionalzedToken?: stringalongsidecontext, and mutating activity results carry thewritten_attoken back. Both belong in
packages/temporalize/src/workflow-types.ts— it is import-free byconstruction and is already the module holding the contract both halves share
(
workflow-types.ts:83,:166). The generic CRUD workflows inworkflows.tsthread it: capturefrom a create/update/destroy result, pass into the next call. Userland workflows opt in the same
way.
createSpiceizePermissionResolverreadsreq.zedTokenand installs it on the principal, sosubsequent 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:
SpicedbOutboxmodel gets its own taskqueue and its own worker, scaling independently of the API workers — which is exactly what a
drainer wants.
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
Watchis a long-lived gRPC stream;DEFAULT_ACTIVITY_OPTIONSis a one-minutestartToCloseTimeout(packages/temporalize/src/workflows.ts:40-42). So the Watch consumer is aheartbeating activity with a
heartbeatTimeout, checkpointing the last-seen ZedToken asheartbeat 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 (
createWorkersbuilds one worker per queue,packages/temporalize/src/worker.ts), andcreateActivitiesalready accepts amodelsnarrowingargument (
activities.ts:68-71). So the schema-plane bulk check for a given worker only needsthat 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
continueAsNewcarry their own inputs and are userlandsurface — 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) againstspicedb 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 muststop passing within the cache TTL.
8.
scope— a permission that rewrites the query — split out to #40The mechanism plane B needs is a new permission kind:
scope, a predicate that returns aportable filter instead of a boolean, AND-ed into the operation's
wherefor reads andwrites 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/
isAllowedhazards, the portable-filter merge, the four enginechokepoints (
resolveFindAll,processUpdate,processDelete,processSelect),setforcreate, nested relationship mutations, the
createRoleBasedPermissionssugar, and the bypasstest matrix.
What this proposal needs from it:
LookupResourcesreturns an id set, and an id set is aportable 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
temporal-workflowsis a separate subpath on purpose: the Temporal workflow bundle runs in anisolated 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.tsandworkflow-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-nodeand staysGraphQL-free.
gqlize/nestize/temporalizeare optional peer deps reached only throughsubpath exports, exactly as
graphql-typesand the adapters are wired. Graph becomes:The ormize plugin API itself lands in
ormize(§2), not in spiceize.10. Milestones
scopepermission key. Tracked and specified separately; listed here because M4 cannot land without it@azerothian/spiceizecore: client,Principal, consistency policy, per-request memo + bulk coalescing; test harness onspicedb serve-testingredis-memory-serveris pinned for the valkey adapter)spiceize print/check/apply.zedround-trips throughWriteSchema/ReadSchema;checkexits non-zero on drift; hand-written fragments preserveddirect+outboxmodes, idempotent TOUCH/DELETE writes, ZedToken capture; drainer + Watch reconciler shipped as temporalize workflows (§7b)scopeprovider (§8, on top of #40): prefilter/postfilter/none, count semantics, relationship policy, mutation gates incl. nested relationship mutationsnode(id:)and relay id fetcher;totalexact under prefilter; nested-mutation bypass testpermissionProfileartifact wiring, nestizeresolvePermission, zod4 per-profilegqlize checkclean per profile; SpiceDB down ⇒ throws, never an open schemacreateSpiceizePermissionResolver, verifyingprincipalFrom,zedTokenon the wire contract + threading in the generic CRUD workflows, per-worker model scoping{ userId: "admin" }with no valid token is rejected; revoked role stops passing within the TTL; stable bag identity keepsregistry.schemasmemoizedexamples/spiceize-basic+examples/spiceize-temporal, docs (guide + spec §7/§13 updates), CI service containers (SpiceDB, Temporal)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
prefilter vs postfilter on a representative dataset before defaulting.
LookupResourcesblowup. Fine under ~10k accessible resources, degrades after. Need aconfigured ceiling that falls back to postfilter + page-fill rather than materializing a
100k-element
INclause.directmode's window must bedocumented rather than glossed.
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.
N models × gatesis small, but a wide field-level plane(
gqlize_field:*) grows it. Cap it: field-plane objects only for fields that declare one.for the TTL. Options: short TTL, explicit
restrictor.invalidate(signature)called from theRole/RoleGrantmutation path (the plugin can wire this itself), or Watch-driven eviction.totalsemantics (§6) — needs a decision before M4 starts, since it is user-visible API.rolemediate 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
.zedmaterially.principal.attributes;in scope as a pass-through, out of scope for codegen in M2.
contextis caller-supplied and unverified, and the README teaches{ userId, role }.Under spiceize that becomes subject spoofing. Needs a verifying
principalFromand adocumented breaking-ish expectation for existing temporalize users, who are currently
allowed to pass whatever they like.
ActivityRequestgains a field (§7a.5). Adding optionalzedTokenis 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: trueworker option) ordefault-on before M6.
scoperisks 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 againstgate.ts'sdocumented 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.
LookupResourceson every request, per model, is alatency 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.
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.