Skip to content

[Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520

Description

@objectstack-fleet

Ruled: 5965059068 · letter A · 2026-10-03T03:27Z

Filing gate: ② a decision only the maintainer can make. It is a security boundary, and the existing rules do not decide it. Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U). It answers the pm:retriage on #21454 (5964337187), items 3 and 4. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, doors and roles only.

Reader who acts: the maintainer, or the director seat. The domain:cli seat then dispatches the ruled letter.

维护者速读

  • 问题: 存放元数据的那组系统表(平台内部存放各类元数据定义的表)里有敏感内容。平台已经规定:任何读出这些内容的通道都必须先做遮蔽。[finding] [security] An action/automation body's object API and an action handler's engine handle read the stored-metadata family outside its body projection and keyed serve (reach NOT MEASURED) #21454 的修复把应用代码体读取的通道补上了,但开发实测还剩两个口子:
    1. 应用包可以把一个「钩子」绑到这些表上。平台自己保存元数据时会触发它,钩子拿到的上下文里就是未遮蔽的原始内容,钩子还能把内容抄进普通记录。难点在于:钩子的输入同时也是它写回的通道,所以不能简单遮蔽。
    2. 一个被授予提权的动作代码体,可以绕过元数据协议直接写这些表,写完返回的那一行也是未遮蔽的原样。
  • 选项:
    • A: 应用代码体完全不能碰这组表。绑定钩子时直接拒绝;提权代码体直接写也拒绝。改元数据只能走元数据接口,那里有校验和来源记录。
    • B: 允许绑钩子,但上下文先遮蔽,并拒绝写回敏感列;提权代码体的直接写改为转交元数据协议处理。
    • C: 维持现状(提权就是信任),只把返回给调用方的内容遮蔽掉。
  • 分诊推荐: A。理由:这组表只该有一个写入方;在编写和注册时就拒绝,比在运行时打补丁可靠(Prime Directive 12)。
  • 你要做的: 回一个字母。

Measured (the #21454 fix dev, os-dev report 5964258278, out-of-scope findings 3 and 4, after PR #21513's seam)

  • Item 3: a hook bound to a family table, in a bundle authored by an administrator, is fired by the metadata door's own save. Its context, both its input and the before and after rows, carries the stored content unprojected, and its body copied that into an ordinary record.
    • Position: packages/runtime/src/sandbox/body-runner.ts, buildSandboxContext.
    • Not mechanical: a hook's input is also its write-back channel, so projecting it would write the projection back.
  • Item 4: an elevated action body can write the family tables through the in-process write verbs, which bypasses the metadata protocol. The written row comes back in its stored form.

Governing text

Premises (each with its re-check)

  1. No shipped app or example binds a body hook to a family table, and no shipped body writes one. NOT MEASURED. Re-check: a census of examples/** and objectstack-ai/hotcrm hook bindings and body write targets.
  2. Platform code (not bodies) that writes the family goes through the metadata protocol. NOT MEASURED. Re-check: the claim's census before any refusal lands.

Independent of the ruling (already routed on #21454)

Dedupe: MCP search_issues, repo-scoped, for 「hook bound to sys_metadata elevated body writes metadata table directly bypass protocol」 → 8 hits, none this question: #21470 (open, the save door's name check), #15206 (open, tenancy of the family), and six closed cards on other subjects.

Activity

  1. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #272 item 1 · letter A · maintainer 「同意」 2026-10-03T03:26Z

    Director seat, summon #32, session_016tKoy8NJa35Yih1FdzrVmn. Written as objectstack-fleet[bot] through the relay. ⛔ Classes, doors and roles only.

    • How it was ruled. Batch 🔗 Broken links detected in documentation #272 was presented in the live director chat with options A, B and C. The director recommended A, with fallback B, and measured the card's two NOT MEASURED premises (below). The maintainer answered the batch as presented: 「同意」.
    • The freshness gate: this card has no comment.

    The census that filled premise 1 (read this session)

    Zero app bodies bind a hook to the stored-metadata family's tables, and zero app bodies write them directly, in objectstack examples/** (ad7c351898), hotcrm (a9bb57b3ec) and cloud (c5a4c9e6cb). The only object: 'sys_metadata' hits are the platform's own list views in packages/metadata-core. So A breaks no shipped app.

    The ruling

    A: an app-authored body may not touch the stored-metadata family's tables.

    • Binding a body hook to a family table is refused at registration, with a prescription naming the metadata API.
    • A direct write to a family table by an elevated body, through the in-process write verbs, is refused at the seam, with the same prescription.
    • Changes to metadata go through the metadata protocol only, where validation and provenance live.

    This is now governing text: for app-authored bodies, the metadata protocol is the family's only writer. No ADR stated it before.

    • Platform code is outside this ruling: the metadata protocol, its own internal writers and the platform's own objects.
    • The census of platform writers (premise 2) is the claim's to take before the refusal lands, so the seam refuses bodies only.

    Not taken:

    • B: a projected hook context with writeback refusal, and redirecting writes. Its hard case, where a hook's input is also its write-back channel, is the kind of special case that leaks over time.
    • C: elevated means trusted, projecting returns only. That leaves a silent copy-out path.

    四棱(本裁决新记录)

    • ① 长远:这组表只有一个写入方(元数据协议:校验 + 来源记录);在注册/写入时拒收,不在运行时补丁(Prime Directive 12)。
    • ② 拉动:口子已实测;正当用途四仓零处。
    • ③ 防 AI:绑定或写入当场按名拒收并给处方;C 是静默外泄。
    • ④ 不扩散:两处拒收,无新机制。
    • 只看①选 A;②③④ 是否翻转:否。

    Execution parameters (ruled here; no further decision card)


    Generated by Claude Code

  2. added a commit that references this issue on Oct 3, 2026
  3. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer from the domain:cli seat (session_016GiHYRmLSNWTfbX9gVQkpz) · 2026-10-03T04:39Z · ⛔ Not a claim. ⛔ Classes, doors and roles only.

    One position for this card's claim, measured by the #21454 round-2 dev (os-dev report 5965573345, out-of-scope finding 1): a write verb's own where predicate over the family's body or content-hash column evaluates it. It is the same oracle a read's predicate is, measured through an elevated sandboxed body's write verbs. PR #21539 serves write RETURNS and deliberately leaves write predicates alone, because refusing a body's family-table write is this card's ruling (A, 5965059068). Refusing the write at the seam closes this predicate path with it.

    Where it attaches: the same write verbs packages/runtime/src/stored-metadata-reader-seam.ts wraps, in serveRepository's write branch, as a throw BEFORE the write runs. PR #21539 names the point in a code comment.

    Serial: this card is held behind PR #21539 (#21454), which edits the same seam file.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (moved to the front by the maintainer: 「21520 插队」)
    Session: session_016GiHYRmLSNWTfbX9gVQkpz
    Account: os-bill (the seat's linked user as get_me answers it; the card's assignee)
    Branch: claude/issue-21520-family-body-boundary
    Worktree: objectstack-issue-21520
    Domain: domain:cli
    Seat: domain:cli#1
    File surface, per the ruling 5965059068 (A, maintainer 「同意」) and triage 5965718076 (the write predicate rides this claim):

    domain:cli seat · session_016GiHYRmLSNWTfbX9gVQkpz · 2026-10-03T06:03Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21520,
      "status": "done",
      "branch": "claude/issue-21520-family-body-boundary",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21563",
      "session": "session_016GiHYRmLSNWTfbX9gVQkpz (the dispatching PM session; this run is its subagent)",
      "premise_still_valid": true,
      "summary": "Ruling A is implemented with two refusals in packages/runtime, each PERMISSION_DENIED / 403 with a prescription naming the metadata API. BINDING: a hook with a sandboxed body whose object names sys_metadata or sys_metadata_history (string or list form) is refused at registration in hookBodyRunnerFactory. That is the one point every door's body hooks pass through: the artifact binder (boot, install-local install and rehydrate) and the runtime-authored metadata-service bind through the engine's default runner. bindAppArtifactHandlers alone would miss that last door. A wildcard body hook still binds, but its body is never run for a family table's event, and the bind says so once at info. WRITING: a sandboxed body's write of either table through ctx.api is refused before the write runs: every write verb and every derived context, elevated or not. A refused write runs nothing and answers the same whatever its predicate names, which closes the predicate oracle triage carried onto this card. A1's assumption measured FALSE: the read seam is not body-only, because buildActionApi also serves host code handlers. So the write refusal is a separate body-only layer in the seam file (refuseStoredMetadataBodyWrites, sharing one derived-context walk with the read seam), applied only at buildSandboxApi, not a throw in serveRepository's shared write branch. Census by TS AST symbol walk: every platform writer is module code calling the engine or driver, and none goes through a sandboxed body. A3: PERMISSION_DENIED fits truthfully, so no new ledger code and no packages/spec edit. Reach was measured as classes before the fix. PR #21539 landed during the run and origin/main was merged on top, so the PR is not stacked: 7 files vs main.",
      "tests": "All heavy runs went through scripts/pm/os-verify-lock.sh (slot issue-21520-dev); each verdict line read VERDICT command-exit 0 unless stated otherwise. FINAL HEAD 9a95e459d1: runtime --project local: Test Files 316 passed (316), Tests 4444 passed | 19 skipped. --project repo: Test Files 3 passed, Tests 751 passed. pnpm --filter @objectstack/runtime typecheck: exit 0, with check:test-typecheck OK and the ledger unchanged; tsc --listFilesOnly on tsconfig.test.json lists all 4 new runtime files. New pins: stored-metadata-body-boundary.test.ts 8/8, stored-metadata-body-writes.test.ts 10/10, stored-metadata-body-boundary.pin.test.ts 7/7 (composed kernel, boot in beforeAll); with the merged seam unit test, 35/35 unit cases. REACH (A5), before the fix: the pins ran against BASE fd5a1cd597's body-runner.ts, then the file was restored and proven (blob == HEAD, git diff HEAD empty). Unit tier: 6 red, 2 controls green; a body hook on each family table bound and ran via the artifact binder, the runtime-authored default runner and the wildcard. Composed tier: 4 red, 3 controls green; the metadata door's save ran the explicit, wildcard and runtime-authored family body hooks (neutral markers on the saved row), and an action body's sys_metadata insert answered 200 for both administrator and member. ABLATION, fix committed first, at 0d8af06c80, via scripts/ablation-replace.mjs: each anchor hit 1 to 0, the blob changed, and the restore was proven each leg. Leg A1 (registration throw disabled): 5 red, 20 green; the composed no-body-ran pin held through the dispatch-side check. Leg A2 (dispatch-side check disabled): 2 red, the wildcard pins. Leg B (body write layer removed from buildSandboxApi): 4 red, both sandbox unit pins and both composed write pins. Direction as predicted: red. Pins resolve body-runner.ts by relative path (src), so no dist leg applies. body-runner.ts and the seam file at the final head are byte-identical to the ablated commit's.",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack with no paths at 9a95e459d1 derived 62 families. All 62 were run, each exit 0. --ran (with exit codes recorded) reconciled 62 derived, 62 run, 0 NOT-MEASURED (a derived zero). check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (no full build); after turbo run build (72/72 tasks) it measured 106 entries across 66 packages, exit 0. Lint, a proven narrowing (pnpm lint itself is CI's): population from eslint's own config, 6 of the 7 changed paths linted (the changeset .md matches no configuration); count from --format json, 6 files, 0 errors, 0 warnings; invariance: eslint.config.mjs enables no type-aware linting and its only import rule (no-restricted-imports) is per-file. check:nul-bytes OK; control-byte self-scan of the 7 changed paths: grep exit 1 (none). CI: not awaited (in_progress).",
      "line_budget": "n/a",
      "mcp_calls": "0",
      "api_writes": "3. Each is one dispatch to the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]). (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls, draft #21563; read back as 11761 bytes sent and 11761 stored, identical. (2) label-write.mjs --assign os-bill: POST /repos/objectstack-ai/objectstack/issues/21563/assignees; read back MATCHES (the path labeler's size/l is preserved and is not mine; no labels were written, since the dispatch named none and skip-changeset does not apply). (3) post-stamped.mjs: POST /repos/objectstack-ai/objectstack/issues/21520/comments, this os-dev-report. git push is not a REST write.",
      "deviations": "1. WRITE-REFUSAL ATTACH POINT, which differs from A4, the seat's pointer and the coordinator's later update (all name serveRepository's write branch). Measured: that branch is the repository of every context the read seam serves, including buildActionApi, which is a host code action handler's ctx.api as well as an action body's. A throw there would refuse deployer host code, but the ruling text says the seam refuses bodies only, and triage 5964836549 put deployer host code outside the family. So the refusal is a body-only layer in the same seam file, applied only at buildSandboxApi; the branch's comment now says why. Raised as open question 1 for the seat to confirm. 2. STACKING: per A4, the open PR #21539's head (19b2cb6e50) was merged into this branch, which AGENTS.md Multi-agent §2 calls an unsupported stacked form; the conflict is stated here, not resolved silently. After #21539 landed, origin/main (ce532184d1) was merged on top per the coordinator. The seam file's #21539 bytes equal the landed squash, so the conflict resolved to this branch's side, and the PR's diff vs main is exactly 7 files and not stacked. 3. BINDING POINT: hookBodyRunnerFactory, not bindAppArtifactHandlers (A2): the artifact binder does not see runtime-authored hooks, while the factory is shared by every door. 4. WILDCARD: a '*' body hook is not refused (refusing it would refuse every wildcard body hook, with no ruling for that), but its body is never run for a family table's event, since ruling A says a body may not touch the tables; an info line at bind says so. 5. A5 NARROWING: the composed reach and pins use neutral markers and scalar predicates only. A write predicate over the body or hash columns was not constructed as a composed probe (the card's no-recipe constraint); the unit pin asserts that the refusal answers identically whatever the predicate names and runs nothing. 6. CODE: 'ledgered' is read as a member of the ledger's ErrorCode union (StandardErrorCode plus ERROR_CODE_LEDGER), since the ledger's admission rule sends a generic permission condition to PERMISSION_DENIED. 7. The ablation commit (0d8af06c80) predates the two origin/main merges; the two ablated files are byte-identical at the final head. 8. The harness attribution reminder names a model in its trailer and an emoji PR footer; commits carry the model-free trailer pair and the PR body ends with the AGENTS.md session-URL footer.",
      "files_changed": [
        ".changeset/21520-body-family-boundary.md (new; @objectstack/runtime minor; Clause-②: yes (narrowing); ADR-0087 not-required (no-migration-prescription))",
        "packages/runtime/src/stored-metadata-body-boundary.ts (new: the two refusal builders, PERMISSION_DENIED / 403, metadata-API prescription)",
        "packages/runtime/src/sandbox/body-runner.ts (binding refusal in hookBodyRunnerFactory; wildcard dispatch-side check and info line; buildSandboxApi layers the body write refusal)",
        "packages/runtime/src/stored-metadata-reader-seam.ts (refuseStoredMetadataBodyWrites body layer; the derived-context walk shared as deriveThroughSeam; doc comments updated)",
        "packages/runtime/src/stored-metadata-body-boundary.test.ts (new, 8 binding pins on a real engine)",
        "packages/runtime/src/stored-metadata-body-writes.test.ts (new, 10 write pins, counting double plus the real sandbox)",
        "packages/runtime/src/stored-metadata-body-boundary.pin.test.ts (new, 7 composed-kernel pins)"
      ],
      "open_questions": [
        {
          "question": "Confirm the write refusal's attach point. The pointer, A4 and the coordinator's later update name serveRepository's write branch, but that branch also serves a host code action handler's ctx.api (buildActionApi), and the ruling says the seam refuses bodies only.",
          "options": [
            "A: keep the body-only layer in the seam file, applied at buildSandboxApi (as delivered). Bodies are refused; host code handlers keep their writes, served on return.",
            "B: move the throw into serveRepository's shared write branch. That also refuses deployer host code handlers' family writes, a wider boundary than ruling A sets, needing a ruling of its own."
          ],
          "recommendation": "A, because it is what the ruling text says (the seam refuses bodies only), it keeps one derived-context walk shared by both layers, and the census shows no body-reachable platform writer that A would miss. B widens the boundary to deployer code, which triage ruled outside the family."
        }
      ],
      "out_of_scope_findings": [
        "class: c · reach: public door, wrong answer, measured. PUT /api/v1/meta/hook/:name, as the administrator, answered 200 for a runtime-authored hook whose body targets sys_metadata, recorded by the composed pin. The runtime then refuses it at bind (logged at error, never fired), so authoring accepts a hook the runtime will not honour. Seam: spec:data HookSchema object target → runtime:sandbox/body-runner.ts hookBodyRunnerFactory (bind refusal); the save door does not consult the boundary. The fix belongs at authoring/publish (a HookSchema refinement, domain:spec, or the metadata door's save validation, protocol.ts, held by PR #21473). The family's collection card, if one exists, is the home. · dedupe words: metadata door accepts hook on sys_metadata; hook body target stored-metadata table refused at bind not at save; runtime-authored hook family table 200 then refused",
        "carrier: none (承接者:无) · noted, not filed. An expected body refusal (this boundary's, like any body throw) is logged by the existing body-runner catch at error as '[BodyRunner] sandboxed action threw'; the boundary did not change that posture."
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21563 at 9a95e459d1, pending its contract review

    domain:cli seat · session_016GiHYRmLSNWTfbX9gVQkpz · review of record, read on GitHub 2026-10-03T08:12Z · ⛔ classes, doors and roles only

    Shape:

    Against ruling A (5965059068) and triage 5965718076, read from the diff:

    • Census first: a TS AST symbol walk finds every platform writer of the family is module code calling the engine or driver. None goes through a sandboxed body, so the refusals hit app-authored bodies only.
    • Binding. A hook with a sandboxed body whose object names either family table, alone or in a list, is refused at registration in hookBodyRunnerFactory. That is the one point every door's body hooks pass through: the artifact binder on boot, install-local install and rehydrate, and the runtime-authored hooks the binder alone would miss.
      • A '*' body hook still binds, but its body never runs for a family table's event, and the bind says so once at info. Refusing every wildcard hook would have no ruling behind it.
    • Writing. A sandboxed body's write of either table through ctx.api, on every write verb and every derived context, elevated or not, answers PERMISSION_DENIED / 403 before the write runs. A refused write runs nothing and answers the same whatever its predicate names, which closes the write-predicate oracle triage carried onto this card.
    • Codes: PERMISSION_DENIED is a ledgered code that truthfully names the condition, so there is no new code and no packages/spec edit. The prescription names the metadata API.
    • ⛔ No B mechanism (no projected hook context, no write redirect).

    The open question (the write refusal's attach point): answered A, as delivered.

    • The seat's own pointer (5965604037) and its update named serveRepository's shared write branch. The dev measured that branch serves a host code action handler's ctx.api too.
    • The ruling says the seam refuses BODIES only, and triage (5964836549) put deployer host code outside the family.
    • So the refusal is a body-only layer in the same seam file (refuseStoredMetadataBodyWrites), applied at buildSandboxApi, and it shares one derived-context walk with the read seam. The seat's pointer was imprecise. The ruling text governs.

    Pins and reverse verification:

    • Binding: 8 unit pins on a real engine. Writes: 10 pins. Composed kernel: 7 pins. The platform's own hooks still fire on the metadata door's save, and ordinary tables bind and write as before.
    • Reach was measured red before the fix:
      • unit tier: 6 red;
      • composed tier: 4 red, including a family body hook fired by the door's save, and an action body's family insert answering 200 for administrator and member.
    • Ablation legs A1, A2 and B each went red where predicted, with restores blob-proven.
    • Full runtime suite: 4444 passed.

    Changeset:

    • @objectstack/runtime minor, with Clause-②: yes (narrowing);
    • one ADR-0087 marker, not-required (no-migration-prescription);
    • a **BREAKING** paragraph naming, as classes, what is refused, what is unchanged and the route (the metadata API). It matches the diff.

    Gates: 62 derived, all 62 exit 0. The lint narrowing is proven. CI on 9a95e459d1 is to be read at landing. needs:contract-review is hung on the PR in this act. An at-tier review of record runs once CI settles.

    Out-of-scope:


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21563 → bd70706713

    domain:cli seat · session_016GiHYRmLSNWTfbX9gVQkpz · read 2026-10-03T09:03Z · ⛔ classes, doors and roles only


    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

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:clipriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions