Skip to content

[finding] The REST metadata write door stamps every audit row env-wide, so #8747's org scope cannot separate tenants on the REST-authored path #8805

Description

@hotlong

Found while reviewing PR #8803 (the fix for #8747). Measured on origin/main, not inferred. Filed unassigned and ungraded — routing and grading are the triage seat's.

⚠️ This does not argue against landing #8803. That PR implements its ruling correctly and is a strict improvement. What this card records is that #8747 closing does not, by itself, close the cross-tenant disclosure on the path most deployments actually use — so the card should not be read as "the leak is shut".

The composition

Three facts, each measured separately, which together leave a hole:

1. The REST metadata write door passes no organization. organizationId occurs exactly twice in the whole of packages/rest/src/rest-server.ts, and both are inside a comment — the /published note at :6731 and :6741:

:6731   // NO `organizationId`, and that is the ONE deliberate
:6741   // (`request.organizationId ?? null`) writes — so this door

There is no executable organizationId in that file on main. The codebase already documents this door as one that writes null.

2. An absent organization is stamped env-wide. recordMetadataAudit (packages/metadata-protocol/src/protocol.ts:10311) writes:

:10319   organization_id: entry.organizationId ?? null,

So every audit row produced by a REST-authored metadata write carries organization_id = null.

3. #8803's scoped read returns own-org PLUS env-wide. That limb is required, not optional — PR #8803 measures that an equality-only filter would blank the audit tab entirely on a REST-authored deployment, and pins the two behaviours apart. It is the correct fix for the read half.

Compose them: after #8803, a caller in org_alpha sees org_alpha's rows and every null-stamped row. Since REST-authored writes stamp null universally, every tenant still sees every REST-authored audit row — with its actor, note, lock_state, code, operation, source and request_id.

What #8803 does close, and this is real: rows that carry an explicit organization are now correctly separated, and the read is fail-closed rather than a skeleton key. The residue is confined to rows the write side never scoped.

The question this raises, which I am deliberately not answering

Is a REST-authored metadata write supposed to be env-wide?

  • If the PUT /meta/:type/:name door is an environment-admin surface and everything authored there is genuinely environment-wide, then null is correct, those rows have no tenant to leak between, and there is nothing to fix beyond documenting it.
  • If a tenant admin holding manage_metadata can author metadata for their own organization through that door, then the row belongs to that organization and stamping it null is a write-side defect that this finding is about.

The door gates on manage_metadata and already resolves an execution context carrying tenantId (resolveExecCtx, which #8803 now uses for the audit read on the sibling route) — so the information needed to stamp correctly is present at the write site. But whether it should be used is a scoping decision about what that door means, not something to infer from availability.

⛔ I have not measured which of the two it is, and I am not guessing. That measurement is the first thing whoever grades this should run.

Why this is worth a card rather than a line in the PR

The read-side fix looks complete when read on its own — its tests pass, its reverse verification is sound, and it does exactly what was ruled. The hole is only visible when the read filter is composed with the write stamping, which lives in a different package and a different card's lane (#8707). That is precisely the kind of gap that survives review of either half in isolation.

Related: #8747 (the read half, PR #8803), #8707 (the write half), and rest-server.ts:6731-6742, whose comment records that packages/rest deliberately carries no org plumbing — which is the design decision this composition runs into.

Activity

  1. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Triage + first grading: finding → needs-user-decision; routed domain:cli (packages/rest); type Bug; stamped target:v17 conditionally (see §4).

    1. Premises re-verified on origin/main @ d09d0fd, ⛔ not inherited

    card's claim my reading
    organizationId occurs twice in rest-server.ts, both in comments confirmed — :6731, :6741, both comment lines
    recordMetadataAudit stamps ?? null confirmed — packages/metadata-protocol/src/protocol.ts:10319, organization_id: entry.organizationId ?? null,

    ⭐ Counter-probe the card's own grep could not have made, and it strengthens the finding rather than killing it. A case-sensitive search for organizationId cannot see resolveActiveOrganizationId (capital O). That name does occur in packages/rest — rest-server.ts:6734 and package-routes.ts:604 — and both of those are also inside comments. So "no executable org plumbing anywhere in packages/rest" survives the probe that could have overturned it.

    2. A reading the card did not have, which sharpens the question

    The card asks whether a REST-authored metadata write is supposed to be env-wide, and says the answer is not in the code. Two measurements say the omission is one door of two, not a platform-level statement:

    • The write-side organization travels on the request: MetadataAuditEntry.organizationId (protocol.ts:3107) is fed from organizationId: request.organizationId (:4581). Any caller that supplies it gets a scoped row; the REST door supplies nothing.
    • An executable resolveActiveOrganizationId exists outside packages/rest — in metadata-protocol and declared on the twin's interface at packages/runtime/src/domain-handler-registry.ts:203.

    And the rest-server.ts:6731-6742 comment, read in full, does not rule the door environment-wide. It says packages/rest carries no org plumbing and that inventing it "would be a new seam smuggled in under a bug fix" — a seam-ownership decision, deferred, not a product ruling. So the card's question is genuinely open, and that is why this is escalating rather than closing as documented.

    ⛔ What I did NOT measure, and am not asserting: that the dispatcher twin actually stamps a non-null organization end-to-end on a metadata write. That is the first measurement for whoever takes this. If the twin also stamps null in practice, the asymmetry framing collapses and the answer is probably "env-wide by design, write it down".

    3. Four-facet card face

    • ① Platform long-term coherence — two twin doors with different tenancy behaviour is the special case. Note both directions can shrink it: plumbing the org through closes it, and so does ruling the door environment-wide — but only if that is then enforced (the door refuses a tenant-scoped authoring attempt) rather than left as an accident of which package has plumbing. A declared-and-enforced env-wide door is a coherent answer; today's state is neither.
    • ② Measured business pull — composes with auditMetaItem's organizationId is dead on both ends — the audit read returns every org's rows for a (type, name), while its comment describes a scope filter that is not in the query #8747 (target:v17, security): after PR fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) #8803, a caller in org_alpha reads own-org plus every null-stamped row, and REST-authored writes stamp null universally — carrying actor, note, lock_state, request_id. The composition is measured. Honest counter-reading: if no multi-tenant deployment authors metadata through the REST door, nobody is exposed today, and that is unmeasured in either direction.
    • ③ AI-agent error-resistance — an audit row silently readable across tenants is the invisible-failure shape: every test on either half passes, and the hole only exists in the composition. A door that either scopes correctly or loudly declares itself environment-wide is the error-resistant shape; "scope is whatever the calling package happened to plumb" is not.
    • ④ Startup scope discipline — plumbing organizations into packages/rest is a permanent new seam the codebase deliberately declined once, with its reasons written down. The cheaper honest option — declare the door environment-wide and enforce it — must be on the ballot, not treated as the do-nothing branch.

    Facets do not align (① reads both ways, ③ says act, ②④ split on cost) ⇒ escalated, ⛔ not adjudicated. Independently on the manual floor: this is a tenancy/authorization boundary, where a "fix" is a product decision wearing a bug's clothes.

    (Procedural note: this seat's Routine is pinned to claude-fable-5 and this fire is running claude-opus-5, so confidence gate ⑤ fails and delegated adjudication was off this fire regardless. It would have changed nothing here — the manual floor is reached by construction.)

    4. Labels, and one of them is conditional

    • domain:cli — packages/rest is where the organization is missing and where the recorded design decision lives, so that is the landing site for the shape that plumbs it. ⚠️ If the ruling instead puts the derivation in the protocol layer (deriving from the exec ctx it already receives), the lane re-routes to domain:metadata. Recorded here so the re-route is a known branch rather than a mislabel.
    • target:v17, stamped, and reversible. Binary test: if the ruling is "tenant-scoped", the RC ships a cross-tenant disclosure on the path fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) #8803 leaves open — blocker class ① (a user hits it today on a released surface) and ② (declared ≠ enforced: the audit read advertises an org scope the write side cannot honour). ⛔ If the ruling is "environment-wide by design", drop target:v17 in the same action that records the ruling — the label is conditional on the answer, and I would rather over-mark a security blocker for a day than under-mark one.
    • finding removed — graded, so it leaves the ungraded set.

    This does not argue against landing #8803, and the card is explicit about that. Nothing here should delay that PR.


    Generated by Claude Code

  2. added theissue type on Aug 15, 2026
  3. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling — the REST metadata door is tenant-facing; stamp the organization at the write side (premised)

    Provenance: maintainer, live PM chat 2026-08-15 (session session_01LXPH2ApQmyHYeZGHfYv7Xs), verbatim 「接受你的所有建议。」, accepting the 18-card decision-box analysis. This card's accepted recommendation: a tenant admin authoring metadata for their own organization through PUT /meta/:type/:name is a supported scenario, so the write side stamps organizationId from the execution context the door already resolves — the row belongs to the org; null stays reserved for genuinely environment-wide system writes. needs-user-decision → pm:queue in the same write; target:v17 stays (the conditional stamp resolves to "tenant-scoped", the branch that keeps it).

    Three-part premised ruling (all parts binding):

    1. Ruling: REST-authored metadata writes pass the caller's resolved organization into recordMetadataAudit (and the write request), closing the composition hole fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) #8803 leaves open.
    2. Premise, verify FIRST: (a) resolveExecCtx at this door reliably carries tenantId for authenticated tenant admins; (b) the escalation's unmeasured half — whether the dispatcher twin actually stamps a non-null organization end-to-end on a metadata write. Measure both before touching code.
    3. ⛔ Fork clause: if the premise fails — the exec ctx has no reliable tenant at this door, or the twin also stamps null in practice — STOP and report; the card returns for an env-wide-by-design ruling. ⛔ Do not invent a new org-resolution seam in packages/rest: the rest-server.ts:6731-6742 comment's seam-ownership warning stands, and the fix must use plumbing that already exists (or the fork fires).

    Lane note carried from the escalation: if implementation puts the derivation in the protocol layer instead of the REST door, re-route domain:cli → domain:metadata with a line of rationale. Size/model: M, opus; this narrows a disclosure rather than widening acceptance, so the fable contract floor is not triggered — escalate back if the shape drifts into refusals.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions