Skip to content

multi-org: sys_file is never org-stamped — StorageMetadataStore.createFile inserts without a context while both callers hold the session #12745

Description

@os-litant

Split out of #11611 (customer question: "platform/system tables never carry organization_id — by design, or planned?"). #11611 resolved 7 of its 8 table families; sys_file is the one that could not be answered without a ruling, so it gets its own card rather than being decided inside a question thread.

Not graded, not routed. This is a decision card: the measurement is settled, the intent is not.

Measured, on origin/main at 34d30118c

1. The insert carries no context. packages/services/service-storage/src/metadata-store.ts:195:

async createFile(rec: FileRecord): Promise<FileRecord> {
  const now = new Date().toISOString();
  const full: FileRecord = { created_at: now, updated_at: now, ...rec };
  if (!this.engine) {
    this.files.set(full.id, full);
    return full;
  }
  await this.engineOp('sys_file', 'insert', FILE_INSERT_CONSEQUENCE, (engine) =>
    engine.insert('sys_file', full),
  );
  return full;
}

Reverse control: the string context appears 0 times in the whole of metadata-store.ts. There is no context-threading that is merely being dropped — there is no channel at all.

git show origin/main:packages/services/service-storage/src/metadata-store.ts | grep -c context   # → 0

2. sys_file is a tenancy-ENABLED object. packages/services/service-storage/src/objects/system-file.object.ts:21 declares no tenancy key at all in its 183 lines, so isTenancyDisabled() (packages/spec/src/data/object.zod.ts:700) reads false and the registry injects organization_id as it does for any business object. The column exists on the table; nothing writes it. That is the shape the reporter measured: sys_file sits in #11611's 233-row "config & metadata misc" bucket, 100% NULL.

3. Both callers already hold a session. packages/services/service-storage/src/storage-routes.ts:320 and :424 call store.createFile({...}), and each one reads the session two lines below — owner_id: session?.userId at :330 and :435. The organization is available at the call site; it is simply not part of the store's signature.

4. sys_file has no sibling with this door. Zero non-test call sites of engine.insert('sys_attachment', …) anywhere in the repo — sys_attachment has no context-free service-store door of the kind sys_file has, which is what makes option B's "the boundary is sys_attachment" a testable claim rather than an assertion.

⚠️ Read-visibility consequence — load-bearing for A

Forward-stamping is not visibility-neutral on a walled deployment. The SQL driver's tenant predicate is NULL-tolerant (packages/drivers/driver-sql/src/sql-driver.ts:11683):

(organization_id = :tenantId OR organization_id IS NULL)

but Layer 0 AND-composes a strict predicate above it. packages/plugins/plugin-security/src/tenant-layer.ts:101{ organization_id: <id> } is "the isolated wall, AND-composed unconditionally"; and packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts:40 states the consequence in terms:

Layer 0's strict organization_id = :tenant AND-composes over the driver's compatibility arm and the conjunction is the strict equality alone.

So under group/isolated, once stamping starts: new files become org-walled, existing NULL-org files become invisible to every principal. A forward-only stamp therefore splits the table's read behaviour in two. Whether that is acceptable, or whether A must ship with a backfill, is part of what is being decided here — this card does not assume it.

(single posture is inert — computeTenantLayer0Filter returns null — so nothing changes for single-tenant installs under any option.)

Options

A — rule it a gap. Thread the session organization through StorageMetadataStore.createFile so new rows are stamped. Forward-stamping only; a backfill of the existing NULL rows is a separate decision, and per the paragraph above it may be a required companion rather than an optional one on walled deployments. Cost: a signature change on a service-store method plus its two call sites.

B — rule it by design. sys_file is the blob ledger (fileId ↔ backend key), deliberately org-less because a stored blob is addressed by key and can be shared; the tenancy boundary is sys_attachment, which binds a file to a record that is itself org-scoped. Under this ruling sys_file should say so out loud — tenancy: { enabled: false }, the ADR-0066 platform-global posture — rather than carrying an always-NULL column that reads as an unfinished migration. ⛔ This inherits #11611's unresolved column-drop question: turning the key off stops the registry injecting organization_id, and whether the schema sync then DROPs the existing column on a customer database is not established here. Unlike the job family (5,613 rows), the column is 100% NULL here, so no data is at stake — but it is still destructive DDL on a production table, which is maintainer floor either way.

C — defer / record only. Note that the customer explicitly asked us not to take this one: #11611's ask was to "know whether org markers will be added later, so we can decide whether to account for it in operations", and C returns exactly the non-answer they wrote in to avoid.

Recommendation

A. The asymmetry in fact 3 is the argument: every other platform family in #11611 was either resolved or had a principled reason to be org-less, whereas here the organization is sitting in the caller's hand two lines from the insert and is dropped only because the store's signature has no place to put it. B is defensible as architecture but is a post-hoc reading of an absence, and it asks for destructive DDL to make the absence honest.

Not established here

  • No claim about whether A needs the backfill or can ship forward-only — the visibility split above is measured, its acceptability is not judged.
  • Whether the schema sync DROPs a de-injected tenant column (B's real cost) is unmeasured.
  • No severity grade. Nothing here is a malfunction: the reporter's own framing is "everything works".

Re-check

git show origin/main:packages/services/service-storage/src/metadata-store.ts | grep -c context
git grep -n "tenancy" origin/main -- packages/services/service-storage/src/objects/system-file.object.ts
git grep -n "owner_id: session?.userId" origin/main -- packages/services/service-storage/src/storage-routes.ts
git grep -n "AND-composes over the driver" origin/main -- packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts

Dedup

Searched the org-scoping family: #11611 is this card's parent and is being closed against it; #3249 (ADR-0066 platform-global posture) and #3623 / ADR-0105 D2 (group-posture read reach) are the mechanisms cited above, not duplicates; #11303 (PR #11698) is forward-stamping for a different table set and does not reach sys_file. No match.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions