Skip to content

fix(core,objectql): effective object permissions read the external write gate, so a read-only external object is no longer offered New, Import, Edit or Delete - #30416

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-q2mcix
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-q2mcix

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#3 · os-bill · session_01Utp6CXSTW6WZn81ULyBVdb

Requested by Bill · project thread
Fixes #30370

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:对外部只读对象(数据源 external 且未双重开写),服务端给控制台和 current_user.can() 的有效权限不再含新建、编辑、删除、转移,可用操作里也去掉了 create/update/delete/upsert/bulk/import。
  • 为什么改:这类对象的写入早就被引擎一律拒绝(403 EXTERNAL_WRITE_FORBIDDEN),但权限答复仍说可写,管理员看到新建/导入/编辑/删除按钮,填完表单才被拒。
  • 风险与回滚:只收紧本来就写不进去的对象的答复,托管数据源对象字节不变;回滚即 revert 本 PR。
  • 先看:packages/core/src/security/effective-object-permissions.ts(新夹紧步骤与 apiOperations 过滤)、packages/objectql/src/engine-registration.ts(写闸判定抽成一处,谓词与拒绝共用)、packages/plugins/plugin-hono-server/src/current-user-endpoints.ts。
  • 两分钟验证:pnpm --filter @objectstack/core exec vitest run src/security/effective-object-permissions.test.ts,预期 49 条全过,其中 6 条 [Effective object permissions ignore the external write gate, so the console offers New, Import, Edit and Delete on a read-only external object #30370]。
  • 需要你做的:无,席位落地。

What changes

Root cause. buildEffectiveObjectPermissions (@objectstack/core) folds permission sets, super-user wildcards, the managed-write guards and API exposure into the map that /auth/me/permissions serves and current_user.can() reads, and its stated purpose is to reflect what the server actually enforces. It never read ADR-0015 §5.3 Gate 3, the external write gate objectql applies to every insert, update and delete (assertWriteAllowed: a federated datasource, and not both datasource.external.allowWrites and object.external.writable). So on a read-only external object the map granted writes the data plane refuses 403 EXTERNAL_WRITE_FORBIDDEN, and the console's resolveAffordance offered New, Import, Edit and Delete.

  • @objectstack/objectql — ObjectQL.isExternalWriteForbidden(objectName): the gate's verdict as a read-only question. The gate is now judged once, in externalWriteGateClosure (engine-registration.ts); assertWriteAllowed throws on that same closure (message unchanged), and the predicate answers whether it is non-null. The engine.ts member is a one-call delegation stub (check:facade-delegation-only green).
  • @objectstack/core — a new optional isExternalWriteForbidden accessor on EffectiveObjectPermissionsSchemaSource, and engineExternalWriteForbidden(engine) (asks the engine's member when present; an engine without it forbids nothing, the registryObjectPackageDisabled shape from disabled package: /auth/me/permissions still grants its objects so current_user.can() stays true, and an app action whose flow ships in the disabled package is still drawn and fails with Flow not found #28877). In buildEffectiveObjectPermissions, after the managed-write clamp: the verdict is judged for every entry before anything changes (a throwing predicate is reported once and clamps nothing), then clampExternalReadOnlyWrites sets allowCreate / allowEdit / allowDelete / allowTransfer to false and lowers modifyAllRecords to the viewAllRecords read it implies (otherwise the spec's objectPermissionGrants would still answer edit / delete / transfer true on it). annotateEffectiveApiOperations takes the same verdicts as an optional third argument and drops the write operations (create, update, delete, upsert, bulk, import, restore, purge) from what such an object is served, always annotating it. Read and export are untouched.
  • @objectstack/plugin-security (getEffectiveObjectPermissions) and @objectstack/plugin-hono-server (/auth/me/permissions) both hand over engineExternalWriteForbidden(ql), so the two consumers stay byte-equal.
  • No ui/** change: the console already reads apiOperations and can().

Expected side effect. Server-side current_user.can(object, 'create' | 'edit' | 'delete' | 'transfer') now answers false on a read-only external object, matching what the write itself always met.

Tests

  • packages/core/src/security/effective-object-permissions.test.ts — new [#30370] describe (6 cases): explicit grant, plain wildcard and super-user wildcard all lose every write verb and keep read and export; modifyAllRecords lowered to the read; a narrowed apiMethods object is served its narrowed reads only; an open gate or no accessor leaves the map byte-identical; a throwing predicate clamps nothing and warns once; engineExternalWriteForbidden asks the engine's own member.
  • packages/objectql/src/external-write-gate.test.ts — new [#30370] describe: over the double opt-in matrix (neither, datasource only, object only, validate-only, both, managed) the predicate's verdict equals what insert, update and delete each meet (code EXTERNAL_WRITE_FORBIDDEN, status 403, or no refusal); default-datasource and unknown objects are not forbidden.
  • packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts — the served objects slot for a read-only external object has no write grant and no write operation, byte-equal to buildEffectiveObjectPermissions with the engine's predicate; reopening the gate restores them.
  • packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts — the member's map is byte-equal to the endpoint computation, and current_user.can('deal', 'create' | 'edit' | 'delete' | 'transfer') evaluates false.

Acceptance notes

  • Scope as the seat's claim widened it (6654819469): objectql predicate plus its engine.ts stub, the core step, the two map builders, their tests, one changeset. One file beyond the list: packages/core/src/security/index.ts, the barrel line that exports engineExternalWriteForbidden beside registryObjectPackageDisabled (the two plugins import it from @objectstack/core). No ui/** or packages/spec/** change.
  • Declarations measured from the diff: Clause-②: yes (widening) (new public ObjectQL.isExternalWriteForbidden, new export engineExternalWriteForbidden, new optional accessor and optional third argument on annotateEffectiveApiOperations; a caller passing none gets the old map), Metadata-protocol: none (nothing an author can write changes). patch changesets for the four published packages.
  • The #30334 item 2 refusal line (ui thread) is untouched; with this change the console should no longer reach that refusal from New / Import / Edit / Delete on such an object.
  • Noted, not filed (read-only inference, no public-door reproduction): the same objectPermissionGrants reading means clampManagedObjectWrites can leave edit / delete / transfer true on a guarded managed object when a permission set names that object explicitly with modifyAllRecords: true (the clamp clears the allow* bits only). This PR lowers modifyAllRecords for the external gate only and leaves the managed clamp as it was. Dedupe words: clampManagedObjectWrites modifyAllRecords, managed write clamp super-user explicit entry, objectPermissionGrants modifyAll guarded.
  • Serial note: refactor: move the private docblocks of the hand-series stubs into their hand modules — about 1,900 lines off engine.ts (86 stubs) and about 4,000 off protocol.ts (157 stubs) #30053 slice 2 also edits engine.ts docblocks; the stub added here is a new public member below the private assertWriteAllowed stub and edits no existing docblock. Whichever lands second merges main.

Verification

Head f994c4bc (merge of origin/main at efcc1a7a, 12 commits, none touching the four packages; tests and gates below ran on f875455e, the same diff).

Targeted tests
  • pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/effective-object-permissions.test.ts — 49/49
  • pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/external-write-gate.test.ts — 18/18
  • pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/current-user-endpoints-effective-objects.test.ts — 7/7
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/get-effective-object-permissions.test.ts — 66/66
Package suites and typecheck
  • pnpm --filter @objectstack/core --filter @objectstack/objectql --filter @objectstack/plugin-security --filter @objectstack/plugin-hono-server test — VERDICT command-exit 0: core 134 files / 2995 tests; objectql 550 / 10979; plugin-hono-server 38 / 477; plugin-security 243 / 5357 passed, 45 skipped.
  • pnpm --filter the same four typecheck — VERDICT command-exit 0.
Ablation (one-shot, restored)

node scripts/ablation-replace.mjs replaced the clampExternalReadOnlyWrites(objects, externalWriteForbidden); call (anchor 1 → 0, blob 0c7fdba6 → 9ba853e2) and ran the core file: 2 failed / 47 passed (the explicit / wildcard / super-user case and the modifyAllRecords case). Restored: blob == HEAD 0c7fdba6, git diff HEAD empty.

Derived gates (dispatch-gates --commands, reconciled with --ran)

69 derived, 66 run exit 0, 3 NOT MEASURED (exit 3, prerequisite not met in this container; CI runs them): node scripts/check-plugin-teardown-shape.mjs --self-test (shallow clone), pnpm check:dual-build-cjs-loads (needs a full workspace build), pnpm check:i18n (needs the built CLI). Also run, outside the derivation: pnpm check:facade-delegation-only exit 0, pnpm --filter @objectstack/spec check:liveness exit 0. Reconciliation: ✓ dispatch-gates --ran: 69 derived famil(ies) accounted for — 66 run, 3 NOT-MEASURED.


Generated by Claude Code

A read-only external object (ADR-0015 Gate 3 closed) is served no write
grant and no write operation, so the console stops offering New, Import,
Edit and Delete the data plane refuses. objectql gains the public
isExternalWriteForbidden verdict that assertWriteAllowed itself reads;
both map builders hand it over.

Claude-Session: https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/plugin-hono-server, @objectstack/plugin-security, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via getEffectiveObjectPermissions (symbol, a method of class SecurityPlugin))

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.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 59 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a91409c42e67e9a1c417b8f2efa82c356903a19e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b0d891a90f2686ea325dd583d5508f1fd597776 — the merge of head f994c4bcae2ff346056e57c441c40112b500d99d into base a91409c42e67e9a1c417b8f2efa82c356903a19e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8b0d891a90f2686ea325dd583d5508f1fd597776 && git checkout 8b0d891a90f2686ea325dd583d5508f1fd597776
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a91409c42e67e9a1c417b8f2efa82c356903a19e f994c4bcae2ff346056e57c441c40112b500d99d && git checkout -B drift-repro a91409c42e67e9a1c417b8f2efa82c356903a19e && git merge --no-ff f994c4bcae2ff346056e57c441c40112b500d99d

node scripts/docs-audit/affected-docs.mjs --json a91409c42e67e9a1c417b8f2efa82c356903a19e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a91409c42e67e9a1c417b8f2efa82c356903a19e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: TIER_DEFAULT
Head-sha: f994c4bcae2ff346056e57c441c40112b500d99d
Local-runs: none
Scope: packages/objectql/src/engine-registration.ts, packages/objectql/src/engine.ts, packages/core/src/security/effective-object-permissions.ts, packages/core/src/security/index.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, .changeset/30370-external-read-only-effective-permissions.md

Face: declaration (Clause-②: yes (widening) line-initial on the PR body and the changeset). No published schema, no governed path, no narrowing of an authorable surface, so the lane seat records this review at TIER_DEFAULT. Reads: the full diff (11 files, +391 −27), card #30370 and its triage 6654015112, claim 6654819469, os-dev report 6655769782.

① Derived judgments

  1. One verdict. externalWriteGateClosure is Gate 3 (ADR-0015 §5.3) judged once; assertWriteAllowed throws on it with the same message fields as before, and the new public ObjectQL#isExternalWriteForbidden answers !== null on it. The refusal path is a pure extraction (same early returns: no/default datasource, no definition, managed or absent schemaMode; closed unless both opt-ins), so the gate's behaviour is unchanged and the map cannot disagree with it. The facade member is a delegation-only stub beside assertWriteAllowed's.
  2. The map. After the super-user seed (every registered object gets an entry when a wildcard grants super-read, so the card's administrator case is covered), the wildcard fold and the managed clamp, clampExternalReadOnlyWrites sets allowCreate/allowEdit/allowDelete/allowTransfer false and lowers modifyAllRecords to viewAllRecords (needed: objectPermissionGrants answers edit/delete/transfer on modifyAllRecords alone; the read half is kept). The verdicts are computed in full before any clamp, a throwing predicate is logged and clamps nothing, and the annotation reads the same set, never a second asking.
  3. apiOperations. On a closed gate the served set drops create, update, delete, upsert, bulk, import, restore, purge; read operations and export stay. Such an object is always annotated (served < closure), so the console's default-allow path cannot offer a write. No ui/** change, as the card expected.
  4. Both builders move together. /auth/me/permissions and ISecurityService.getEffectiveObjectPermissions hand over engineExternalWriteForbidden(ql), which asks the engine's member when present and forbids nothing otherwise, so a caller with neither gets the old map. Stated side effect: current_user.can(obj, 'create'|'edit'|'delete'|'transfer') answers false on such an object, as the write always did.

② Semver level

@objectstack/core, @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-hono-server patch: AGENTS.md step 3, a non-protocol widening (new ObjectQL#isExternalWriteForbidden, new export engineExternalWriteForbidden, new optional accessor and optional third parameter) ships patch; Metadata-protocol: none. The served map now grants less on a read-only external object, but only what the data plane already refused for every caller, so nothing a caller could do before is removed. No BREAKING banner owed.

③ Boundary flags

Implemented-by: claude/project-thread-q2mcix
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

席位核对

ACCEPT · head f994c4bcae2ff346056e57c441c40112b500d99d · card #30370 (claim 6654819469) · 2026-11-05T10:58Z

  • Shape: draft PR to main, author objectstack-fleet[bot] (relay), attribution block then Fixes #30370. 11 files, +391 −27. check-governed-merges.mjs --pr 30416: NOT governed, 418 changed lines ≤ 3000 ⇒ ordinary queue landing.
  • Declarations: Clause-②: yes (widening), Metadata-protocol: none; patch changesets for @objectstack/core, @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-hono-server (AGENTS.md step 3: a non-protocol widening ships patch). No BREAKING banner owed.
  • Review of record: seat TIER_DEFAULT record PASS on this head (6655806610).
  • Proof (dev, report 6655769782): targeted suites green in all four packages, full package suites 134 / 550 / 38 / 243 files green, typecheck green, ablation red and restored; check-facade-delegation-only and check:liveness green; 66 of 69 derived gate families exit 0, 3 NOT-MEASURED for an unmet precondition (shallow-clone teardown self-test, dual-build CJS load, i18n), all three also run in CI here.
  • Gates (check-runs on this head): 63 runs, 54 success, 9 skipped, 0 failed; Test Core, Lint & Repo Gates, TypeScript Type Check, Test UI, Live E2E UI green. Skips read by name (path-gated, opt-in harnesses): check-expected-skips.mjs cannot run in this container (yaml not installed).
  • Accepted deviations: one export line in packages/core/src/security/index.ts beyond the claimed surface; assignees: ["os-bill"] on the create, per the dispatch order.
  • Landing: ready + auto-merge by the seat. The dev's Acceptance-notes observation (the managed clamp and an explicit modifyAllRecords) is verified and filed by the seat after landing.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 10:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 10:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit f7afab8 Nov 5, 2026
64 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-q2mcix branch November 5, 2026 11:24
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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Effective object permissions ignore the external write gate, so the console offers New, Import, Edit and Delete on a read-only external object

2 participants