One org. Departments that do not mix.
orgScope is a soft, hierarchical, host-resolved partition inside a single org: a canonical path like finance/emea that keeps one department's knowledge, skills, runs and threads out of another's. It sits beside the hard org_id boundary rather than replacing it, and it is inert until you use it: every existing row is org-level, and a deployment that resolves no scope behaves exactly as it did before the axis existed.
Two axes, deliberately
A single N-level hierarchy would have meant rewriting the tenancy boundary — the FK, the RLS policies, is_org_member, and every query that binds tenantId. Two axes keep that boundary untouched and make the new one additive. They enforce different things and they fail differently:
| Axis | Kind | Enforced by | Crossing it |
|---|---|---|---|
| org_id | Hard | FK + RLS + is_org_member, on every table | Impossible |
| orgScope | Soft, hierarchical | the store SQL (plus RLS as defence in depth) | An unrestricted caller, by design — that is what “soft” means |
Say this out loud before you design around it: a scope is not a security boundary against your own server code. The engine reads through a service-role pool, so the code filter is the boundary and RLS is the second line. If two parties must be unable to reach each other even through a bug in your host, give them separate orgs. Scopes are for departments inside one customer.
The grammar, and why it is this narrow
A scope is a /-delimited path. normalizeOrgScope is the one validator, run at every ingestion site and at every write:
- each segment matches [a-z0-9-]+;
- no empty segment — so no leading /, no trailing one, no doubled one;
- segment ≤ 40 chars, whole path ≤ 200, depth ≤ 6 — an indexed text column stays bounded.
The exclusions are the point, not house style. % and _ are absent because the visibility rule below splices a stored scope into the pattern side of a SQL LIKE. A stored % would therefore act as a wildcard and match sibling subtrees — one row turning into a lateral read of the whole org. Uppercase is excluded so two spellings of one department can never become two partitions; . and whitespace so nothing here can be confused with a file path or survive a copy-paste with an invisible character in it.
It rejects; it never coerces. There is no branch that lowercases, trims, or strips a trailing slash. Coercion is how two callers who typed different things end up sharing a partition — and, in the other direction, how a host that meant finance and computed Finance/ is told nothing. "" is invalid rather than unrestricted: a host that computed an empty string from a failed lookup meant “I could not resolve one”, and reading that as “unrestricted” is the fail-open direction.
Where a scope comes from — and where it must never come from
The host resolves it, once, where it already builds an AuthContext. From there it rides SwarmContext → SwarmToolContext → the run/thread snapshot.
import type { AuthContext } from "@nightowlsdev/core";
import { normalizeOrgScope } from "@nightowlsdev/core";
// Your own user record. The scope hierarchy is YOURS — the framework never mirrors it.
interface User { id: string; orgId: string; role: string; department?: string }
// Host auth resolution. This is the ONLY place a scope is decided.
export function resolveAuth(user: User): AuthContext {
if (user.role === "admin") {
// An org-wide operator: omit orgScope entirely. `undefined` = UNRESTRICTED = today's behavior.
// Never `?? "default"` — a missing scope means "not partitioned", not "in the partition named default".
return { tenantId: user.orgId, userId: user.id };
}
// Everyone else is partitioned. normalizeOrgScope REJECTS a non-canonical value rather than coercing it,
// so a typo fails the request here instead of quietly creating a second partition nobody can see into.
return {
tenantId: user.orgId,
userId: user.id,
orgScope: normalizeOrgScope(user.department ?? "unassigned"),
};
}Never a tool argument. A model that can name its own scope has no boundary at all. A tool body reads ctx.orgScope off the trusted run context and nothing else — and a repo-wide test scans every package's tool schemas and fails the build if a scope-shaped key is ever added to one.
If the scope arrives inside a token, canonicalize it through the claim-shaped guard, which treats a present-but-wrong-typed claim as a malformed credential rather than a missing one:
import { normalizeOrgScopeClaim, OrgScopeInvalid } from "@nightowlsdev/core";
// A scope arriving from a token/claim bag is UNTRUSTED INPUT, and the guard is not the obvious one.
export function scopeFromClaims(claims: Record<string, unknown>): string | undefined {
try {
// absent / null => undefined (unrestricted) — the only two inputs allowed to mean that
// a string => canonicalized, or throws
// ANYTHING ELSE => throws. `org_scope: true` and `org_scope: ["finance"]` are MALFORMED
// credentials, not missing ones.
return normalizeOrgScopeClaim(claims.org_scope);
} catch (err) {
if (err instanceof OrgScopeInvalid) throw new Response("bad credential", { status: 401 });
throw err;
}
}
// ⚠ The guard you would have written reads a present-but-wrong-typed claim as ABSENT:
// typeof raw === "string" ? normalizeOrgScope(raw) : undefined
// ...which promotes the caller to UNRESTRICTED. That is the one direction this axis must never fail.Rule A: what a scoped caller can see
One visibility rule, applied by every read path on every scoped plane. A caller sees org-level rows, its own scope, and its ancestors'. Siblings and descendants are not visible.
| Caller | Row's scope | Read? | Write? |
|---|---|---|---|
| undefined | anything | Yes | Yes |
| finance/emea | null (org-level) | Yes | No |
| finance/emea | finance (ancestor) | Yes | No |
| finance/emea | finance/emea (own) | Yes | Yes |
| finance/emea | finance/apac (sibling) | No | No |
| finance/emea | finance/emea/uk (descendant) | No | No |
| finance/emea | finance-emea | No | No |
Deeper is wider, not narrower. A deeper scope has more ancestors, so it reads more. That inverts the intuition, and it is why “a manager should see the whole subtree” is not expressible by giving the manager the parent scope. Resolve that person to undefined instead. A subtree set (scopes?: string[]) is a later additive widening, not something to emulate with the wrong end of this rule.
The last row is the one that bites: finance-emea is a different department. The rule matches on '/%' rather than '%' so a hyphenated name that happens to start with a parent's letters is never treated as a child. Every plane's test suite has a cell for it.
Writes land at exactly one scope
Reads use Rule A; mutations require the exact scope. The two rules are deliberately different, and the difference is the useful part: a finance/emea caller can see the org-level corpus and cannot prune, delete, or re-tag it. A write always lands at exactly ctx.orgScope — never at an ancestor it merely reads through.
Denials are shaped so they are not an existence oracle. A scoped delete that matched nothing is { deleted: 0 }, not an error — the same non-leaky answer the cross-tenant case already gives, so a caller cannot probe for what a sibling partition holds.
Three per-plane consequences worth knowing before you adopt:
- Knowledge: one document identity belongs to one scope. Re-ingesting an existing (org, source_type, source_id) under a different scope throws KnowledgeScopeConflict rather than re-labelling it — in both directions, including an unrestricted caller re-ingesting a scoped document. Moving one is setScope, which is unrestricted-only and is one UPDATE, not a re-embed. That is precisely why the scope lives outside the identity key.
- Skills: most-specific-wins, once. A finance overlay of writer beats the org-level writer for a finance caller. Ranking applies to head resolution and nowhere else: a version read resolves through the exact head the caller can see, never a second ranked query, because two ranked lookups can disagree the instant an overlay is published between them.
- Runs and threads: the thread is the source of scope truth. A run's scope is derived from its thread, and creating one requires an exact match — with no unrestricted exception. Scratchpad, messages and task lists carry no scope column at all and must not get one; they read it off the owning thread. A resumer's scope governs what they may address; the run's own snapshot governs what the engine writes, so a finance/emea human can legally resume a finance run and that run can still reach a terminal.
undefined is unrestricted — and it is not the same everywhere
On the engine path, undefined means unrestricted: today's behavior, and the reason adopting this axis is a no-op until you resolve a scope. There is no "default" scope, and a scope must never be produced by a ?? "default" fallback — a lint-shaped test fails the build if that string appears next to a scope read.
Client-direct database reads invert it. If you expose PostgREST or a Supabase client straight to the browser, those rows are served by RLS reading a JWT claim rather than by trusted host code — and an absent claim there is indistinguishable from one that was never minted or was stripped. So that path is fail-closed:
| Path | Where the scope lives | Missing / invalid |
|---|---|---|
| Engine (trusted host code) | AuthContext.orgScope | Unrestricted |
| Realtime tokens (own mint) | the org_scope claim | Unrestricted — the mint is authoritative |
| Supabase access JWTs (PostgREST/RLS) | app_metadata.org_scope, keyed by org id | Org-level rows only |
The access-JWT claim is an object keyed by org id ({"<org-uuid>": "finance/emea"}) so a scope resolved under one org membership can never apply to another; "*" means unrestricted. Wiring it is a documented host obligation — an auth-hook recipe ships with the migration. Legacy deployments see no change, because no row carries a non-NULL scope until you set one.
Migrations: what to eject, in what order
Four migrations across two packaged sets. The new columns they add (runs/threads/skills/kb_documents's org_scope, plus skills.archived_at) are nullable with no default, so the columns are a no-op for existing rows. The migrations are not purely additive beyond that, and it is worth knowing which parts take effect immediately: knowledge_0003 issues a grant and replaces the read policy; 0030 replaces the skills unique constraint (drops the old head key, adds the widened one); knowledge_0004 adds a CHECK and replaces the read policy; and 0032 creates/replaces functions and replaces policies. The scope parts of these (0030's widened key, 0004's CHECK, 0032's scope terms) are live the moment they apply but inert in practice until you opt in — no row carries a non-NULL scope and no client-direct reader holds a scope claim yet. knowledge_0003 is the exception: it is not scope-gated at all. It is the FR-051-era fail-closed visibility floor, so its grant and its visibility-aware policy take effect on your existing rows immediately — a client-direct reader that could previously see a non-default visibility (e.g. quarantine) stops the moment you apply it, regardless of scope adoption. A hand-ejecting host must take all four or the axis is half-present.
| Migration | Set | What it adds |
|---|---|---|
| knowledge_0003 | knowledgeMigrations() | The visibility-aware read policy and the grant select 0001 never issued. Not scope — the fail-closed floor underneath it. |
| 0030 | MIGRATIONS (storage-supabase) | The columns: runs.org_scope, threads.org_scope, skills.org_scope + skills.archived_at, their indexes, and the widened skills head key. Structure only, zero behavior. |
| knowledge_0004 | knowledgeMigrations() | kb_documents.org_scope, a CHECK pinning the canonical form, its index, and the scope term on the client-direct read policy. |
| 0032 | MIGRATIONS (storage-supabase) | is_scope_member plus the realtime receive gates and the client-direct RLS terms, parents and the child closure. |
Installing via @nightowlsdev/cli ejects the storage set for you. If you eject by hand, re-eject both sets rather than cherry-picking: the CHECK constraint in knowledge_0004 re-states the charset ban at the database, which is what protects a restore or a backfill that never went through normalizeOrgScope — and 0032 without 0030 references columns that do not exist.
Both RLS halves are defence in depth, stated plainly: the engine reads through a service-role pool that bypasses RLS, so the code filter in the store is the boundary. RLS matters for the second path to the same rows — a browser talking to PostgREST. Replace nightowls.kb_scope_readable(uuid, text) or is_scope_member to override, exactly as with is_org_member.
Offboarding a scope
A client leaves. The two planes answer differently because their schemas differ, not because the design wavered — and the honest v1 says out loud what is not covered.
Wire the audit sink first. Without it the knowledge verb refuses to run:
import { createKnowledgeStore } from "@nightowlsdev/knowledge";
export const kb = createKnowledgeStore({
dbUrl: process.env.DATABASE_URL!,
embedder: myEmbedder,
// FR-055 §5 — WITHOUT this, deleteByScope throws. This package keeps no audit table, so a bulk
// deletion that nothing records is refused rather than performed silently.
onOffboard: async (record) => {
await myAuditLog.write({
action: "knowledge.offboard_scope",
actor: record.actor,
scope: record.scope,
subtree: record.includeDescendants,
deleted: record.deleted,
sources: record.sources,
at: record.at,
});
},
});
declare const myEmbedder: import("@nightowlsdev/knowledge").Embedder;
declare const myAuditLog: { write(row: Record<string, unknown>): Promise<void> };import type { KnowledgeStore } from "@nightowlsdev/knowledge";
import type { StorageAdapter } from "@nightowlsdev/core";
/**
* Offboard a department. Run it as an UNRESTRICTED caller: both verbs reject a scoped one, because
* dissolving a partition is an admin fact ABOUT the partition, not a mutation inside it.
*/
export async function offboardScope(kb: KnowledgeStore, storage: StorageAdapter, tenantId: string, scope: string) {
const actor = { type: "human", userId: "admin-1", tenantId } as const;
// 1. SKILLS ARCHIVE, they never delete: 0026's append-only triggers reject a cascading version delete by
// design. Every resolution path already excludes archived heads, so the scope stops resolving and a
// publish to it becomes a typed SkillHeadArchived. `unarchiveSkill` is the way back, per head.
//
// ⚠ FAIL if the skills plane is present but PREDATES archiveScope. Optional-chaining straight past it would
// read "0 archived", delete the knowledge below, and return success while the department's skill heads
// survive — a partial offboard reported as a clean one. An older adapter must block the whole operation.
const writable = storage.skillsWritable;
if (writable && !writable.archiveScope) {
throw new Error("skillsWritable.archiveScope is unavailable — upgrade @nightowlsdev/storage-supabase before offboarding");
}
const skills = await writable?.archiveScope?.(tenantId, actor, {
orgScope: scope, // REQUIRED — undefined would archive the ORG-LEVEL shared library
includeDescendants: true, // opt-in: also finance/emea, finance/emea/uk, ... but never finance-emea
});
// 2. KNOWLEDGE DELETES — kb_documents is an ordinary table. The `onOffboard` sink you wired on
// createKnowledgeStore is awaited INSIDE the transaction, so if it throws, nothing is deleted.
const docs = await kb.deleteByScope({ tenantId, scope, includeDescendants: true, actor });
// 3. Runs, threads, events and the graph have NO purge path today. Stated plainly rather than promised.
return { skillsArchived: skills?.archived ?? 0, chunksDeleted: docs.deleted, sources: docs.sources };
}- The sink runs inside the deletion's transaction. It is awaited before the commit, so a throwing sink rolls the whole offboard back — an unrecordable deletion never happens. If your sink writes its audit row to the same database, use the second argument (onOffboard(record, tx)): tx.recordAudit(table, row) writes on that same transaction, so the audit row is atomic with the delete and never checks out a second connection — a pool.query would deadlock a saturated pool. It is deliberately a purpose-built insert, not raw SQL: a raw query would let a sink commit and then throw, defeating the roll-back guarantee. An external audit service (as above) needs no tx, but keep it quick: it holds the transaction open.
- Both verbs are unrestricted-only, and they reject a scoped caller even when its scope equals the target. “I am finance, so I may delete finance” is the plausible-sounding version of the escalation.
- Both require an explicit scope. Everywhere else on this axis undefined means unrestricted; here that reading would wipe the org-level corpus and the org-level skill library. There is deliberately no way to spell it.
- An offboard is not a fence. A concurrent ingest can land after the sweep. Revoke the scope's callers first; this is the second step.
- Runs, threads, events and the graph have no purge path today. Said plainly rather than dressed up as a GDPR story. Skill content redaction is deferred for the same reason the sweep archives: the append-only guarantee is load-bearing for surfaces people already use.
- Per-scope cost attribution is a one-line addition to your existing usage sink, not an engine feature: the scope is already on the run row, so read ctx.orgScope in the onEvent observer you already have and key your ledger by it.
Designed, deferred: the planes that are NOT scoped
Knowledge, skills, runs, threads and the task-list plane are scoped. Three planes are not, and the designs below are recorded rather than built — building a partition nobody consumes means shipping a migration and a predicate that cannot be validated against a real use.
- Graph — Option A, as a future migration. A scope column on the four-plus entity/relation tables, the resolution key gains the scope, and expandFrom gains the node join it is currently missing (without which a traversal would step through an out-of-scope node and out the other side). NULL is the org-shared tier, as everywhere else. It lands when a scoped consumer exists, because the hard question here is not the predicate: it is whether entity resolution across departments is a feature (one customer entity, shared) or a leak (two clients' customers merged), and that answer should come from a real deployment rather than from us.
- Agents — audiences already cover reachability. Which callers may see and address which agents is a shipped, fail-closed axis, and it solves the question a department boundary would be asked first. Head-row scoping — a finance overlay of an agent persona, the way skills now work — is v1.1, and is additive on top.
- Connections and the MCP registry — v1.1. A per-scope credential or MCP server is a real ask, but it interacts with the vault's own authorization model rather than sitting beside it, so it is its own design rather than one more column.
Until then those planes are org-wide, which is the truthful state: an agent granted a graph tool in a scoped deployment can traverse the whole org's graph. Plan around that rather than assuming the axis reaches everywhere.
Where to go next
Audiences
The sibling axis, one plane over: which callers may reach which agents.
@nightowlsdev/knowledge
Rule A on all four read paths, setScope, and the two-stage ANN query a scope predicate needs.
@nightowlsdev/storage-supabase
Migrations 0030 and 0032, the skills overlay, and is_scope_member.
Building on Night Owls? See the source on GitHub.