Skip to content

fix(runtime): a metadata write carries the session's organization only for types declaring allowOrgOverride (#7018) - #7060

Merged
os-zhuang merged 9 commits into
mainfrom
claude/issue-7018-runtime-org-threading
Aug 9, 2026
Merged

fix(runtime): a metadata write carries the session's organization only for types declaring allowOrgOverride (#7018)#7060
os-zhuang merged 9 commits into
mainfrom
claude/issue-7018-runtime-org-threading

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #7018

Part of the #6190 ruling — the runtime half; PR #6973 rebases after this and lands as the declared=enforced backstop. Issue #6190 itself stays open here — PR #6973 is the card that closes it, and this PR deliberately declares no closing keyword for it.

The maintainer ruling, quoted verbatim and untranslated from #6190 comment 5230671098:

  1. packages/runtime half, first: domains/meta.ts (and the ADR-0045 visibility flip in domains/packages.ts) thread the session's active organization into saveMetaItem only when the target type declares allowOrgOverride: true; otherwise the write lands env-wide — the same row a no-active-org session already produces today. This is independently correct and fixes the live phantom-write defect on its own (the runtime is already minting rows boot never reads).
  2. PR fix(metadata-protocol): refuse an org-scoped write of a type that has no per-org channel (#6190) #6973 second: after the runtime half merges, rebase and land as the declared=enforced backstop.
  3. Option B (scope parameter) rejected; Option C rejected as unshippable.

The defect

SysMetadataRepository.put stamps organization_id: this.organizationId for every type, and both runtime write sites threaded resolveActiveOrganizationId into saveMetaItem unconditionally. So any session carrying an active organization minted an org-scoped sys_metadata row even for types with no per-org read channel at all — while cold boot (loadMetaFromDb) hydrates organization_id IS NULL only.

Those rows are phantom writes: correct for the life of the process, silently absent after the next restart. The measured specimens are the ones #6190 filed — a flow authored in Studio binds its triggers, fires all day, and stops firing after a restart with nothing said; an object written the same way 404s every record. For allowOrgOverride: true types the identical skip is the ADR-0005 design, because those overlays are loaded on demand by getMetaItem/getMetaItems.

What changed

packages/runtime only. No packages/metadata-protocol and no packages/spec changes.

  • src/meta-write-org-scope.ts (new) — declaresOrgOverride(type) / organizationIdForMetaWrite(type, activeOrg), derived from DEFAULT_METADATA_TYPE_REGISTRY with plural/singular normalization. No parallel allowlist (PD Convert to monorepo with scoped packages #8): the set is computed from the very export ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES derives from, so a registry entry flipping allowOrgOverride moves both sides together.
  • src/domains/meta.tsPUT /meta/:type/:name threads the active org only when the type declares it; otherwise the write lands env-wide.
  • src/domains/packages.ts — the ADR-0045 §3 visibility flip writes app (allowOrgOverride: false) env-wide, on the row cold boot hydrates and the App Switcher reads. The org-scoped flip was itself a phantom that reverted on restart. The getMetaItems read beside it is left org-aware on purpose — a layered read is a superset, never a loss.

Why the static registry flag and not isOverlayAllowed

isOverlayAllowed is a private static in metadata-protocol and additionally consults OS_METADATA_WRITABLE. The predicate here must agree with the one deciding whether the row is ever readable again, and boot hydration keys off the static registry flag alone. reportUnhydratableOrgScopedRows settled the identical question on the read side, in its own words:

Derived from DEFAULT_METADATA_TYPE_REGISTRY and NOT from isOverlayAllowed, because the OS_METADATA_WRITABLE escape hatch only unlocks the WRITE — an env-unlocked type's org rows are hydrated no more than any other's.

So the escape hatch still unlocks the write, and the write still lands env-wide. No needs_decision was required: the clean registry-derived source exists as a public @objectstack/spec/kernel export.

Sweep — are these the only two sites?

Verified against origin/main, not assumed. packages/runtime has exactly two saveMetaItem call sites (the two above) and zero publishMetaItem call sites. The other nine resolveActiveOrganizationId uses are reads (getMetaItem / getMetaItems / listDrafts) or package-level verbs that are not per-type and therefore outside this predicate: publishPackageDrafts, discardPackageDrafts, listCommits, revertCommit, rollbackToPackageCommit, reassignOrphanedMetadata, duplicatePackage, deletePackage, assemblePackageManifest. No third site.

One coupling was checked rather than assumed: a draft that now lands env-wide is still publishable. SysMetadataRepository.listDrafts surfaces env-wide drafts to a non-null-org caller via $or and publishPackageDrafts promotes each draft in its own scope (#3115), so nothing is stranded.

Tests

packages/runtime/src/meta-write-org-scope.test.ts — eight cases, all driving a session that HAS an active organization through the real HttpDispatcher.resolveActiveOrganizationId (a real auth-service getSession shape), the real handleMetadataRequest / handlePackagesRequest, the real ObjectStackProtocolImplementation and the real SysMetadataRepository, then reading the stored row. The pre-existing runtime suite was green on the defect only because nothing in it ever populated session.activeOrganizationId; with no active org the two branches are indistinguishable.

Reverse verification — direction predicted before running

Taking the fix back out with git checkout origin/main -- src/domains/meta.ts src/domains/packages.ts. Predicted 4 red / 4 green; measured 4 red / 4 green:

case with the fix without it
flow write organization_id = null "org_alpha" RED
object write organization_id = null "org_alpha" RED
receipt vs a no-org session identical (org=org_alpha, …) vs (env-wide, …) RED
ADR-0045 app flip 1 row, env-wide, _unpublished:false; env-wide list serves it published TWO rows — env-wide _unpublished:true plus an org-scoped _unpublished:false — and the env-wide list still answers _unpublished: true RED
CONTROL view "org_alpha" unchanged GREEN
CONTROL views (plural URL) "org_alpha" unchanged GREEN
predicate is registry-derived unchanged GREEN
flip logs no degradation unchanged GREEN

The first prediction written into the file was 5 red / 3 green; the measurement corrected it to 4/4. The last case asserts an absence of a degradation line, which the unfixed code satisfies too — its flip succeeds, it just succeeds into the wrong partition. It is kept because it guards the opposite regression (a flip degraded into warn-and-continue, which this route answers 200 through), and its greenness is stated in the file rather than dressed up as a red. The two view controls are not slack either: a "fix" that simply stopped threading the org anywhere would pass every red case and fail there, silently retiring ADR-0005 per-org overlays.

Local runs

pnpm --filter @objectstack/runtime test
  Test Files  116 passed (116)
       Tests  1752 passed (1752)

pnpm --filter @objectstack/runtime typecheck
  > tsc --noEmit          (clean)

Gates enumerated one by one from .github/workflows/lint.yml (ESLint job in full, plus the Type Check job's non-build steps) and node scripts/check-adr-0087-registration.mjs --base origin/main — 43 gates, 42 green on the first lap. The one red was check:engine-double-contract: the new fake engine's update()/delete() accepted shapes the real engine refuses. Both verbs now open with assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core (the gate's own remedy).

Two CI-only reds, both fixed here

Neither was reproducible locally, and both are worth naming because the reason is the same in each case — the merge ref sees things the branch base did not:

  1. Test Core: 403 instead of 200 on every /meta PUT. main moved under this branch — finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603 / PR feat(rest): PUT /meta/:type/:name 要求 manage_metadata 能力 (#6603) #7027 (and #6603's gate does not close #6603: the same masked-schema round-trip deletion is still reachable through the compound-name PUT and the dispatcher PUT (plus an ungated DELETE) #7019 / PR feat(rest,runtime): 元数据写入的其余三扇门同样要求 manage_metadata (#7019) #7043 for the other three doors) put a manage_metadata capability gate ahead of the write. The fixture now grants that capability explicitly, so the file is order- and shard-independent rather than depending on what another suite registered.
  2. TypeScript Type Check: the @objectstack/runtime TEST_DEBT ratchet drifted upward. tsconfig.json excludes *.test.ts, so the package's own typecheck script never saw the new file's 15 errors — the hidden layer [finding] DEBT ledger counts in check-type-check-coverage.mjs drift silently — @objectstack/metadata-protocol records 28, actually reports 63 #5278's ratchet exists to measure. Fixed rather than ledgered: one responseOf() helper replaces 13 TS18048 sites (HttpDispatcherResult.response is optional because a declining route answers { handled: false }), the muting console.warn spy is no longer bound to an unread variable, and the two error.mock.calls reducers are annotated. Measured with the ledger's own method — a sibling tsconfig that drops the test exclusion — 15 errors before, 0 after.

Release

.changeset/runtime-meta-write-org-scope.md@objectstack/runtime patch. No authoring change and no new refusal: writes that succeeded still succeed, with the same response body. What changes is which partition the row lands in for types that never had a per-org read channel. (The bump was authored as minor and set to patch by a concurrent session on this same branch; kept as patch here rather than reverted — flagging it for the reviewer to settle, since the two readings are "behaviour change" vs "fix toward declared behaviour".)


Generated by Claude Code

claude added 3 commits August 9, 2026 10:35
…OrgOverride types (#7018)

The #6190 ruling's runtime half (Option A). Both dispatcher write sites used to
thread `resolveActiveOrganizationId` into `protocol.saveMetaItem`
unconditionally, and `SysMetadataRepository.put` stamps `organization_id` for
every type — so a session with an active organization minted org-scoped rows for
types cold boot never reads (`loadMetaFromDb` hydrates `organization_id IS NULL`
only). Those rows are phantom writes: a `flow` fires until the next restart and
then silently stops; an `object` 404s every record.

- `domains/meta.ts` PUT: the active org rides the write only when the target
  type declares `allowOrgOverride: true`; otherwise the write lands env-wide —
  the same row, and the same receipt, a no-active-org session produces today.
- `domains/packages.ts` ADR-0045 §3 visibility flip: `app` is non-overridable,
  so the flip writes env-wide, on the row boot hydrates. The org-scoped flip was
  itself a phantom that reverted on restart.

The predicate is derived from `DEFAULT_METADATA_TYPE_REGISTRY` (PD #8, no
parallel allowlist) and deliberately ignores `OS_METADATA_WRITABLE` — the same
call `reportUnhydratableOrgScopedRows` already made on the read side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
… dispatch (#7018)

`check:engine-double-contract` flagged the fake engine in
`meta-write-org-scope.test.ts`: its `update()`/`delete()` accepted call shapes the
real ObjectQL engine refuses. Both verbs now open with
`assertEngineUpdateDispatch` / `assertEngineDeleteDispatch` from
`@objectstack/metadata-core` (never `@objectstack/objectql` — that reverse edge
is a cycle turbo refuses), and route by-id dispatches through the bound id.

Also records the MEASURED reverse-verification direction in the file header
(4 red / 4 green, not the 5/3 first predicted) and adds the two registry methods
`getMetaItems` calls on the app-listing path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 1:08pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/permissions/system-context.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026
claude added 2 commits August 9, 2026 12:47
…angeset to patch (#7018)

Rebased-in-place adjustments for the r2 takeover branch, on top of the prior
session's ad11fe5 + 1a01e8d:

- ctx() now grants manage_metadata: the dispatcher's /meta PUT gate (#7019,
  landed on main after the prior branch forked) 403s an unauthorized caller
  before the org-scoping decision these tests pin is ever reached.
- registry stub carries isPackageDisabled + applyNavContributions — the two
  methods getMetaItems grew on main (disabled-package filter, ADR-0029 D7 nav
  merge); without them the ADR-0045 flip cases fail on a TypeError inside the
  flip's try, not on the partition assertion.
- reverse verification re-measured on the merged #7043 base: same 4 red /
  4 green, same failure shapes (header updated in place).
- changeset level minor -> patch: behavioural fix, no new API surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc

Copy link
Copy Markdown
Collaborator

PM coordination (session_01LGRN2cSRfggfX9B2L83bQc) — fast-forwarding this PR's branch to the verified-green head, first-PR identity preserved.

To the prior session (session_01PiRUoQkTSBBmpyXBY3cVn2), whose PR this is: your work is kept in full — this is a fast-forward, not a replacement. Sequence of events, for the record: your draft opened 12:42:21Z (first PR — wins under the dispatch rule; the takeover dev stood down from opening a competing PR). Your head is red on the merge ref for a base-drift reason it cannot see (Test Core (1/3): expected 403 to be 200 at meta-write-org-scope.test.ts:362 — the fork predates #7043's manage_metadata gate). The takeover dev's branch claude/issue-7018-runtime-org-threading-r2 (head 7b9c8f25f) contains your head as an ancestor plus: the origin/main merge, the manage_metadata test-ctx fix, the registry stubs getMetaItems now needs, and the changeset corrected to patch per the dispatch. Verified on that head: runtime 117 files / 1761 tests green, tsc/eslint clean, engine-double-contract 122 pinned, reverse verification 4-red/4-green exactly as predicted (your own 1a01e8d44 independently converged on the same 4/4).

Pushing 7b9c8f25f to this PR's branch now (plain fast-forward — if you have pushed something newer in the meantime my push will be rejected and I will reassess rather than force). Body deltas versus your original report, recorded here instead of editing your body: changeset is now patch (not minor), local figures are 117/1761. ⛔ Please do not force-push over the fast-forwarded head; comment here instead if you disagree with any of the three deltas.


Generated by Claude Code

claude added 4 commits August 9, 2026 12:50
…scope fixture (#7018)

`main` moved under this branch: #6603 / PR #7027 put a `manage_metadata` gate in
front of `PUT /meta/:type/:name`, ahead of everything this file measures. The
fixture's session carried `systemPermissions: []`, so on the PR's merge ref the
door answered 403 and the scoping decision was never reached.

The capability is now granted explicitly rather than inherited from whatever
another suite registered, so the file is order- and shard-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…reading' into claude/issue-7018-runtime-org-threading

# Conflicts:
#	packages/runtime/src/meta-write-org-scope.test.ts
…T ledger (#7018)

`check:type-check-debt` went red on the merged base: `@objectstack/runtime`'s
TEST_DEBT is a shrink-only ratchet (#5278) recording 227 raw errors, and the new
file pushed the measured count to 240. The package's own `tsc --noEmit` never saw
them — `tsconfig.json` excludes `*.test.ts`, which is the hidden layer that
ledger exists to measure.

Fixed rather than ledgered, since none of the 15 were irreducible:

- `HttpDispatcherResult.response` is optional (a declining route answers
  `{ handled: false }`), so every `res.response.status` was a TS18048. One
  `responseOf()` helper says once, loudly, that these routes must answer, and
  hands back a narrowed response — instead of a narrowing dance at each of the
  13 call sites.
- the muting `console.warn` spy was bound to an unread variable (TS6133); only
  the `console.error` spy is read back, so only that one is bound now.
- the `error.mock.calls` reducers carried implicit `any` parameters (TS7006).

Measured with the ledger's own method — a sibling tsconfig that drops the test
exclusion — over this file: 15 errors before, 0 after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 13:28
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

PM step-7 review: ACCEPT — queued (verified queue-branch membership 13:28:49Z).

Settling the report's open question for the record: changeset stays patch. A declared=enforced repair offers no new capability and changes no request/response surface; grading such repairs minor would inflate every future ADR-0049/ADR-0005 enforcement lap into a feature bump. The pre-existing-data discontinuity (org-scoped phantom rows stop being minted; a write that used to vanish on restart now persists) is accurately described in the changeset body, which is where it belongs.

Two notes recorded with the PM ledger: (1) a concurrent session pushed to this branch mid-task (…-r2 commits, merged in rather than overwritten — correct handling; the one-card-two-sessions anomaly is reported to the maintainer separately); (2) the claim-guard red was re-diagnosed by the dev with the guard's own regex — the sole trigger was close #6190 adjacency in the body, which GitHub's linker would equally have acted on at merge time. The guard prevented an un-ruled issue closure; working as designed.

After merge: the metadata seat will be reminded to rebase PR #6973 onto the new base (its rebase surface now also carries PR #7040's two test cases).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

PM step-7 acceptance review — PASS (session_01LGRN2cSRfggfX9B2L83bQc, maintainer-expedited #7018, the #6190-A runtime half).

Verified independently on the fast-forwarded head 7b9c8f25f:

  1. Ruled scope exact: both write sites (domains/meta.ts PUT threading, domains/packages.ts ADR-0045 flip) now condition org-threading on the target type's allowOrgOverride declaration, predicate registry-derived (no hand-copied type list), plural spellings resolved before the predicate. Runtime-only diff — metadata-protocol and spec untouched, exactly as the sequencing requires.
  2. Takeover mechanics clean: first-PR identity preserved (this PR); the prior session's head is an ancestor of the current head (fast-forward, no force); the three deltas (origin/main merge with the feat(rest,runtime): 元数据写入的其余三扇门同样要求 manage_metadata (#7019) #7043 manage_metadata test-ctx fix, registry stubs, changeset minor→patch) are recorded in the 12:5xZ coordination comment rather than silently.
  3. Verification: runtime 117 files / 1761 tests green locally; reverse verification predicted-then-measured 4 red / 4 green exactly (both sessions independently converged on the same 4/4). CI on this head: 26/26 completed, zero failures — including ADR maintainer approval (success, non-ADR diff) and the full required set.
  4. Sequencing forward: on merge, runtime: thread the session's organization into saveMetaItem only for types declaring allowOrgOverride — the #6190 ruling's runtime half, lands FIRST #7018 closes (Fixes #7018); PR fix(metadata-protocol): refuse an org-scoped write of a type that has no per-org channel (#6190) #6973 then rebases onto main and lands as the declared=enforced backstop, closing org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190.

Marking ready and enabling queue auto-merge — standard flow for a non-ADR code PR.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: thread the session's organization into saveMetaItem only for types declaring allowOrgOverride — the #6190 ruling's runtime half, lands FIRST

3 participants