Skip to content

fix(service-datasource)!: the datasource admin door asks the walled-catalog verdict on every row-writing verb, as the /meta door does - #31005

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/sec-hardening-r11
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/sec-hardening-r11

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

Clause-②: no (narrowing) — under the isolated and group tenancy postures, creating, changing or removing a datasource at the datasource admin routes (POST /api/v1/datasources, PATCH and DELETE /api/v1/datasources/:name, POST /api/v1/datasources/:name/migrate-credential) is a write to the environment's metadata catalog, and it is the platform operator's, as every other write to that catalog is.
Metadata-protocol: none
Skills-docs: none — no skills/** text teaches the datasource admin routes or who may call them (0 hits for api/v1/datasources and migrate-credential under skills/; datasource itself is named there, the positive control); the rule is stated in the docs this diff updates (content/docs/deployment/tenancy-modes.mdx, content/docs/data-modeling/external-datasources.mdx).

维护者速读

  • 改了什么:数据源管理路由中写数据源记录的四个动作(创建、修改、删除、迁移凭据),在 isolated / group 租户姿态下,与 /meta 写入门问同一个“围墙目录写入”裁决(walledCatalogWriteVerdict,类型 datasource)。
  • 为什么改:数据源是环境元数据目录里的一个 datasource 条目,与 PUT /meta/datasource/:name 写的是同一条记录;围墙姿态下这个目录只归平台运营方(与引擎自身)写,所以这道门与 /meta 门读同一条裁决、给同一个状态码与同一句话。
  • 风险与回滚:围墙姿态下,平台运营方以下、持有 manage_platform_settings 的调用方(包括组织内的 admin_full_access 持有人)写数据源改为 403 PERMISSION_DENIED,什么都不存、不封存凭据、不重建连接池;single 与未注册租户服务的部署不变;列表、读取、驱动目录、两种连接测试、远程表列表与对象草稿不变;回滚即还原本 PR,已存的数据源不受影响。
  • 先看:packages/services/service-datasource/src/admin-routes.ts(requireDatasourceAdmin 的 catalogWrite 段与 walledVerdictTenancyPosture,以及四条写路由的调用)、packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts(§1 改用运营方作写入对照,§8 / §8b 新矩阵)、packages/services/service-datasource/src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts(被拒的 PATCH 不动存储与连接池)。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/admin-routes-tenancy-posture-admission.test.ts src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts,预期 2 个文件 73 个用例全部通过。
  • 需要你做的:无,席位落地。

What changes

  • The rule. Under isolated and group every organization shares the environment's metadata catalog, and a write to it is admitted only for the platform operator and for the engine's own writes (ADR-0131 D3, D12 items 7–8, D11). A datasource is a datasource item of that catalog, the record PUT /api/v1/meta/datasource/:name writes. The datasource admin door now asks the walled-catalog verdict on every row-writing verb, as the /meta door does: create, update, remove and migrate-credential each ask walledCatalogWriteVerdict (@objectstack/metadata-core) for the type datasource, after the manage_platform_settings capability gate and before the datasource service is looked up. The verdict is reused, not restated.
  • The answer. A refused call answers 403 PERMISSION_DENIED with the verdict's sentence for a datasource item, the sentence the /meta write doors answer for the same record, naming the posture. Nothing is stored, no secret is sealed or removed, and no pool is rebuilt or evicted.
  • The posture judged. On a host whose kernel has the async service accessor, the verdict judges the same effective posture the door's admission already resolved, so one read of the tenancy service decides both. On a host without that accessor, where the admission does not read the posture, the verdict reads a registered tenancy service synchronously; a service that is not registered reads as the no-tenancy composition, inert as single is.
  • Unchanged. The platform operator writes datasources under a wall as before. Under single, and with no tenancy service, every caller holding manage_platform_settings writes datasources as before. Listing, reading, the driver catalog, the draft probe (POST /datasources/test), the saved-datasource probe (POST /datasources/:name/test), the remote-table listing and the object draft write no datasource record and do not ask the verdict; the object draft hands back a definition the caller saves through the /meta doors, which ask it themselves. The capability gate still answers first.
  • Docs and doc comments. The admin routes' docblock states the rule in place of the old "no posture arm" note. deployment/tenancy-modes and data-modeling/external-datasources state who writes a datasource under a wall. The walledCatalogWriteVerdict doc comment and its ADR anchor name the datasource admin routes among the doors that ask it, and a new ADR-0131 anchor holds the rule on admin-routes.ts.

Tests

  • admin-routes-tenancy-posture-admission.test.ts (real ObjectKernel, real resolveAuthzContext, API keys over the shipped permission-store shapes):
    • §1's write controls under isolated are now the platform operator's key (declared in OS_PLATFORM_OWNER_EMAIL over a verified row); they previously pinned a member's create and delete landing under isolated.
    • §8, for isolated and for group, each of the four verbs: refused for a current member holding manage_platform_settings (403, PERMISSION_DENIED, the message equal to the verdict's message for datasource on that posture, the service method never called, the store unchanged); refused for an organization's admin_full_access holder; allowed for the platform operator, and the write lands. The capability refusal still answers first for a caller without the capability. The seven routes that write no record still answer 200 for the member.
    • §8b, under single and with no tenancy service: each of the four verbs is served for the member and the write lands.
  • datasource-update-kept-pool-stores-nothing.test.ts (the real DatasourceAdminServicePlugin and ExternalDatasourceServicePlugin behind the real routes, the real secret binder, a driver factory that records every pool it builds): under isolated and group a PATCH carrying a new secret and a new host by the entitled caller is refused with the verdict's status, code and sentence; the stored record and the sys_secret ids are byte-equal to before, no pool is built, and the serving pool still answers its test. Control: the same PATCH under single stores the new host and builds one new pool. This rig has no getKernel, so it also covers the synchronous posture read.

Acceptance notes

  • Where the rule sits. At the door, beside the capability gate, before resolve(): the service below receives the record, not the caller's rung, and the door is where this family's admission already lives. One option on the shared floor (catalogWrite) is passed by exactly the four row-writing routes.
  • Which verbs write. Decided from the code: the service's write seams (putDatasourceRecord, deleteDatasourceRecord, the secret writes and the pool seams) are reached only from createDatasource, updateDatasource, removeDatasource and migrateCredential. testConnection (draft and saved), listRemoteTables, generateObjectDraft, listDatasources and getDatasource reach none of them.
  • The code. PERMISSION_DENIED, the standard-catalog member this family already answers for a 403, and the code the dispatcher /meta door answers; the REST /meta door answers its grandfathered synonym (STANDARD_SYNONYM_WAIVERS, ADR-0112 D3), which is not endorsed for new code.
  • isSystem. Not passed to the verdict: this family's resolution never carries it from inbound HTTP, so the verdict's engine admission is unreachable here, as before.
  • Breaking, patch. An accept-set narrowing under a walled posture only; no key of any schema moves and nothing stored changes, so the package takes a patch with a BREAKING banner (ADR-0087 marker not-required (no-migration-prescription)).
  • The metadata-core edit is comments only (the verdict's door list), so it publishes no behaviour and carries no changeset of its own.

Verification

All readings below are at head b99ce15091cc324f14293a091741c641936cd413, after origin/main was merged in at e304cae63f (no conflicts; main had moved core/security, metadata-core, tenancy-modes and a service-datasource test), the closure rebuilt and every build and test run under the shared verify lock. main has moved since by 14 commits, none touching service-datasource, metadata-core, the authorization resolver or the two docs pages (one core/security pin test).

Package tests and typecheck
  • pnpm --filter @objectstack/service-datasource exec vitest run: Test Files 72 passed (72), Tests 1279 passed (1279).
  • pnpm --filter @objectstack/metadata-core exec vitest run: Test Files 23 passed (23), Tests 911 passed (911).
  • The two edited pin files hold 73 of those tests (65 in the admission matrix, 8 in the PATCH rig).
  • typecheck for service-datasource, metadata-core and the direct consumers of service-datasource (rest, runtime, cli, verify, dogfood, example-showcase): VERDICT command-exit 0, every package Done, 0 error TS lines. The package's typecheck compiles both edited test files (tsc --listFiles).
  • pnpm --filter @objectstack/spec check:generated after the merge: all 14 generated artifacts up to date.
Ablations

Each through scripts/ablation-replace.mjs (anchor hit once, blob changed, restored blob-equal to HEAD with git diff HEAD empty), on the committed tree. The pins import ../admin-routes.js from source, so no rebuild sits between the mutation and the run. The four route ablations were read at 56a424acf3 (blob c44ff35c97f1); admin-routes.ts has since changed only in a doc comment (blob bf51f83b0cbc). The posture-read ablation was read at the head.

subject ablation reading (73 pins)
create the route stops naming the type it writes 4 failed: the member and admin_full_access refusals, under isolated and group
update the same, on PATCH /:name 6 failed: the same four, and the PATCH rig's refusal under isolated and group
remove the same, on DELETE /:name 4 failed: the member and admin_full_access refusals, both postures
migrate-credential the same, on POST /:name/migrate-credential 4 failed: the member and admin_full_access refusals, both postures
the synchronous posture read the verdict judges the admission posture on every host 2 failed: the PATCH rig's refusal under isolated and group

After each restore the blob equals HEAD's and git diff HEAD is empty; git status --porcelain empty after the last.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at b99ce15091: 102 commands, each run as printed with stdin from /dev/null and its exit code recorded. --ran: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit is 0. Before the merge, at f2dc54d5ea, two first answered exit 3 (check:skill-examples, check:dual-build-cjs-loads: prerequisite packages outside this diff unbuilt) and answered 0 once those were built; they were built before the run at the head.

pnpm check:adr-anchors: exit 0 with the new anchor.

Full dogfood battery at the head

b99ce15091cc324f14293a091741c641936cd413, the closure built at that head, then under the lock pnpm --filter @objectstack/dogfood test with no shard and no path:

  • os-verify-lock: VERDICT command-exit 0
  • Test Files 427 passed | 3 skipped (430)
  • Tests 3556 passed | 41 skipped (3597)
  • no failures

…talog verdict on every row-writing verb

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…ource admin door's row-writing verbs

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…d tenancy posture

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
… rule to ADR-0131

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
… datasource admin routes

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
@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 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/service-datasource, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-core/src/meta-write-capability.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via /:name/test (route, a path literal in a comment on a changed line))
  • content/docs/permissions/authorization.mdx (via /:name/test (route, a path literal in a comment on a changed line))
  • content/docs/permissions/delegated-administration.mdx (via /:name/test (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-6.mdx (via /:name/test (route, a path literal in a comment on a changed line), /meta/datasource/:name (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-7.mdx (via /meta/datasource/:name (route, a path literal in a comment on a changed line))
  • content/docs/releases/v18/18-3.mdx (via /:name/test (route, a path literal in a comment on a changed line))

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/metadata-core/src/meta-write-capability.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 51 pages)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 — 8 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 8ae0ab9b0d77d56473e89dae9fe437778172dda8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3a36dac89f869e9bc72c972dbaf205c3007edbe2 — the merge of head e8ccebeca528cbacef26dbd96e663f190cf2c59d into base 8ae0ab9b0d77d56473e89dae9fe437778172dda8, 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 3a36dac89f869e9bc72c972dbaf205c3007edbe2 && git checkout 3a36dac89f869e9bc72c972dbaf205c3007edbe2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8ae0ab9b0d77d56473e89dae9fe437778172dda8 e8ccebeca528cbacef26dbd96e663f190cf2c59d && git checkout -B drift-repro 8ae0ab9b0d77d56473e89dae9fe437778172dda8 && git merge --no-ff e8ccebeca528cbacef26dbd96e663f190cf2c59d

node scripts/docs-audit/affected-docs.mjs --json 8ae0ab9b0d77d56473e89dae9fe437778172dda8

⚠️ 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 8ae0ab9b0d77d56473e89dae9fe437778172dda8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…t module top in the two walled-catalog suites

check:test-source-alias: both suites reach a dynamic driver import in the
source under test inside a clocked case; the warm import moves that load
to collection, as the gate's remedy and the package's other suites do.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…with a ledgered mock, not a load

The kept-pool suite boots the admin plugin with its own driver factory, so the
default factory's memory arm never runs; a warm import of the driver was a new
binding check:driver-memory-census refuses. A vi.mock replacement satisfies
check:test-source-alias without loading the driver, and is ledgered under the
census's mock-replacement axis.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e8ccebeca528cbacef26dbd96e663f190cf2c59d
Local-runs: none
Scope: packages/services/service-datasource/src/admin-routes.ts, packages/metadata-core/src/meta-write-capability.ts, packages/services/service-datasource/src/tests/admin-routes-tenancy-posture-admission.test.ts, packages/services/service-datasource/src/tests/datasource-update-kept-pool-stores-nothing.test.ts, scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json, scripts/adr-anchors/packages__services__service-datasource__src__admin-routes.ts.json, scripts/driver-memory-census.ledger.json + packages/services/service-datasource/src/datasource-admin-service.ts, packages/services/service-datasource/src/datasource-admin-plugin.ts, packages/services/service-datasource/src/default-datasource-driver-factory.ts, packages/services/service-datasource/src/sqlite-driver-fallback.ts, scripts/check-driver-memory-census.mjs, scripts/check-test-source-alias.mjs (the main copy, which the merge commit runs), packages/services/service-datasource/src/external-datasource-service.ts, packages/core/src/security/api-key.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/posture-ladder.ts, packages/core/src/kernel-base.ts, packages/core/src/kernel.ts, packages/runtime/src/domains/meta.ts, packages/runtime/src/domains/activation-gate.ts, packages/runtime/src/http-dispatcher.ts, packages/rest/src/rest-server.ts, packages/rest/src/meta-item-write-routes.ts, packages/rest/src/external-datasource-routes.ts, packages/services/service-datasource/src/tests/admin-routes-authz-outage-envelope.test.ts

Read as the net diff of PR #31005 against main (merge-base 1aeeafd9d3) at this head, the PR body, the file list, and the check-runs on e8ccebeca5 and on the two previous heads 399785c0b1 and b99ce15091. The prose faces (.changeset/datasource-admin-walled-catalog-verdict.md, content/docs/deployment/tenancy-modes.mdx, content/docs/data-modeling/external-datasources.mdx) were read for ② and ④. Face: the paths decide none and the changeset's Clause-②: no (narrowing) line decides the narrowing face, so this tier.

① Derived judgments

  • Reused, not restated — right. admin-routes.ts imports walledCatalogWriteVerdict from @objectstack/metadata-core and asks it once, inside the shared floor requireDatasourceAdmin, when a route passes options.catalogWrite. No posture test, no rung test and no refusal sentence is written locally; the sentence on the wire is walled.message. The four row-writing routes (POST /datasources, PATCH /datasources/:name, DELETE /datasources/:name, POST /datasources/:name/migrate-credential) each pass the module-private DATASOURCE_METADATA_TYPE = 'datasource'.
  • Asked after the capability gate and before any effect — right. The floor's order at head: ADR-0069 held-session gate, anonymous refusal, manage_platform_settings capability refusal, then the walled verdict, then return false. Every handler calls resolve() (the service lookup and its 503) only after the floor returns, and splitSecret(req.body) and the service call come after that. So a refused call reaches no putDatasourceRecord, writeSecret, removeSecret, pool registration or re-registration. The §8 matrix pins the service method never called and the store equal to the seed; the PATCH rig pins the stored record and the sys_secret ids byte-equal and no pool built.
  • Same status, code and sentence as the /meta door — right, with the transport caveat the PR body already names. The dispatcher's /meta save and _migrate-stored doors answer deps.error(walled.message, 403), which the status map spells PERMISSION_DENIED; this door answers sendError(res, 403, 'PERMISSION_DENIED', walled.message). The REST /meta transport spells its 403 FORBIDDEN, the grandfathered synonym (ADR-0112 D3 waiver), and this door rightly does not copy it. The type handed to the verdict is datasource, the same canonical type PUT /meta/datasource/:name hands it, so the subject in the sentence is the same (a 'datasource' metadata item); the two edited pins compare the wire message to the verdict's own output for that type and posture rather than to a copied string.
  • Posture source matches /meta's — right. Both /meta transports fold the tenancy service through effectiveTenancyPosture (@objectstack/core). This door, on a host whose kernel has getServiceAsync, hands the verdict the admission posture the floor already resolved through classifyAdmissionTenancyPosture, which folds the same service the same way and raises the branded outage rather than a permit; one read decides both decisions. On a host without that accessor the new walledVerdictTenancyPosture reads ctx.getService('tenancy') synchronously and folds it with effectiveTenancyPosture; a throw reads as undefined. That swallow is bounded by the kernel shapes in the tree: KernelBase's getService throws only the not-found error and its registerServiceFactory is unsupported, while the is async - use await throw exists only on ObjectKernel, whose plugin context carries getKernel with getServiceAsync, so the fallback is never reached there. The PATCH rig (no getKernel) pins the fallback reading a registered posture and refusing under both walls.
  • Caller rung from the same resolution as /meta — right. callerPosture is authz.posture off resolveAuthzContext; the dispatcher's ec.posture is the same field copied by assemble-execution-context.ts. isSystem is not passed: ResolvedAuthzContext carries none and inbound HTTP never does, so the verdict's engine admission is unreachable here, as it was before; the sibling import door passes authz?.isSystem, which is the same answer for a wire caller.
  • Operator, single and no-tenancy hosts unchanged — right. The platform operator rung PLATFORM_ADMIN derives from the platform anchors (derivePosture), never from a held set, so the operator passes the verdict with any key or session that resolves it; §8 pins the operator's key served on all four verbs under isolated and group with the write landing. The verdict returns allowed for an undefined posture and for single; §8b pins all four verbs served for the capability holder under single and with no tenancy service. §1's write controls moving to the operator's key is the right consequence under isolated, and the member's writes under a wall are now §8's subject.
  • The seven non-row routes are unchanged and write nothing — right. GET /datasources, GET /datasources/drivers, GET /datasources/:name, POST /datasources/test, POST /datasources/:name/test, GET /datasources/:name/remote-tables and POST /datasources/:name/object-draft pass no catalogWrite. Read in the services: the admin service's store writes (putDatasourceRecord at lines 932, 1115, 1326, 1361; deleteDatasourceRecord at 1216), writeSecret, removeSecret and the register/re-register/unregister pool seams are reached only inside createDatasource, updateDatasource (and its discardUnstoredSecret), removeDatasource and migrateCredential; testConnection calls only config.probe; listDatasources and getDatasource read. The external service's persistObject and persistCatalog are reached only in importObject and refreshCatalog, which have their own walled door (fix(rest,runtime)!: the import doors and the catalog refresh read the walled-catalog verdict the metadata save door reads #30702), never in listRemoteTables, testConnection or generateObjectDraft. §8 pins all seven answering 200 for the member under both walls with the store unchanged.
  • Public surface — none moves. No exported symbol of @objectstack/service-datasource or @objectstack/metadata-core changes; the metadata-core edit is two doc-comment door lists; the ADR anchors state the rule the code carries; the datasource docblock's old "no posture arm here" paragraph is replaced because what landed is the imported verdict, not a second local policy.

② Semver level

③ Boundary flags

  • (1) Ablation head vs this head — the claim holds. git diff 56a424acf3 e8ccebeca5 -- packages/services/service-datasource/src/admin-routes.ts is +5 lines and 0 removed: one paragraph in the DATASOURCE_ADMIN_CAPABILITY docblock (blob c44ff35c97 to bf51f83b0c); no code line moved, and no source file of the package moved after b99ce15091. The route ablations read at 56a424acf3 therefore transfer. Two seat commits sit on top of b99ce15091, both on test files and gate bookkeeping only. 399785c0b1 (+7 lines): module-top side-effect imports of @objectstack/driver-sql in both suites and of @objectstack/driver-memory in the PATCH rig, the remedy the check:test-source-alias gate printed on b99ce15091 (the suites reach a dynamic driver import inside a clocked case through sqlite-database-filename.ts, datasource-admin-plugin.ts and default-datasource-driver-factory.ts); that gate read OK on 399785c0b1. e8ccebeca5 (+16/-1): in the PATCH rig the @objectstack/driver-memory warm import is replaced by vi.mock('@objectstack/driver-memory', …) whose factory exports an InMemoryDriver class that throws on construction, with a three-line comment; and one otherDeclarations entry (kind: mock, axis: mock-replacement, with its why) is added to scripts/driver-memory-census.ledger.json, the shape the ledger's existing mock entries take. That answers the one red gate on 399785c0b1 (check:driver-memory-census: the warm import was a new import binding of that driver with no ledger row; 63 of 64 gates in the lane passed). The ledger file is the gate's own bookkeeping and publishes nothing. Nothing was run here.
  • The mock is sound and changes nothing the suite proves. Read in the tree: every case in the PATCH rig boots DatasourceAdminServicePlugin with the suite's own counting passwordFactory (driverFactory is taken before the datasource-driver-factory service lookup, datasource-admin-plugin.ts line 396), every datasource it creates is driver: 'postgres', and the suite's store is a hand-rolled map, so the default factory's memory arm (default-datasource-driver-factory.ts line 1349) and the SQLite fallback's in-memory arm (sqlite-driver-fallback.ts line 318), the only two reaches of @objectstack/driver-memory in the package, are never called. No case depends on the real in-memory driver, so the mock removes no behaviour from what the pins measure; and because its constructor throws, any future case that did reach that arm would fail loudly rather than pass on a quiet stand-in. The reach the alias gate judges is static, and main's copy of that gate credits a vi.mock of the specifier the same as a module-top load.
  • (2) Re-merge — not owed. The merge-base with main is 1aeeafd9d3; the 14 commits main gained since touch no path of this diff (the intersection of the two file lists is empty). The one file in a neighbouring area, packages/core/src/security/second-object-read-exposure.pin.test.ts, is a pin this PR does not read or edit. GitHub reports the PR mergeable with a clean merge; the queue builds the merge commit. One of those 14 commits (acd190b223) is the half of check:test-source-alias that judges a changed test's reach into the source it loads, and it sits only on main: the PR tree carries the older script, so a local run of that gate at the branch tree does not measure the half that went red on b99ce15091; the check-run on the merge commit does, and it read OK on 399785c0b1. The census script is the same on both sides; only the ledger gains a row here. No re-merge is owed for the code; the merge-commit check-runs are the reading that counts.
  • Pre-existing gaps, outside this PR, at rule level. (a) On a host without an async service lookup the admission's posture read stays unread, as the service-datasource: the admin routes supply no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15350/After all four tenancy-posture seams land: fold their local resolveAdmissionTenancyPosture copies onto one helper — the CLASSIFICATION only, not the resolution #16013 wiring left it; those lines are unchanged here. This PR gives the walled verdict its own read on that host shape, so the walled rule does not inherit the gap; closing the admission's own gap is a change to that wiring with its own pins, not this ruling's. (b) A tenancy service that is registered and cannot answer raises the branded outage out of the floor, unchanged by this PR, which adds no throw path. At this door the shipped host's rendering of that throw is pinned 503 SERVICE_UNAVAILABLE (admin-routes-authz-outage-envelope.test.ts); the sibling import door answers the outage inside its guard instead. Whether this floor should answer it inside its guard too, so every host spells it 503, is a follow-up on the floor, not on this narrowing.
  • Check-runs. On b99ce15091 the one red gate was Test-source alias gate inside Lint · CI, release & engine gates; on 399785c0b1 the one red gate was driver-memory census gate in the same lane (63 of 64 gates passed each time; Test Core, Test UI, Dogfood Regression Gate, Live E2E UI, Build Docs, every other lint and type-check lane, Check Changeset, Governed Surface Queue Guard, PR-body guards and Spec property liveness were green on 399785c0b1). The aggregate Lint & Repo Gates and TypeScript Type Check lanes went red on both from that one gate and from lanes cancelled when the head moved. On e8ccebeca5, at the time of this record (47 check-runs): green Lint · CI, release & engine gates (the lane that carries both gates named above, so the alias gate and the census gate both read OK on this head), Lint · repo & tooling gates, Lint · ESLint, Lint · docs & code guards, Lint · scoped · tooling self-tests, Lint · scoped · slot lookup & corpus, Lint · PM dispatch-gates, Type Check · source gates, Test UI, Dogfood Regression Gate, Plan Test Core shards, Build Docs, filter, Auto Label, PR-body guards, Governed Surface Queue Guard, Check Changeset, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Check PR Size; still pending Build Core, Test Core (all 8 shards), Live E2E UI, Lint · scoped · query-options erasure, Type Check · workspace, Type Check · consumer gates, Type Check · debt ledger; the UI, temporal and opt-in lanes skipped as before. None red. Their conclusions are the gate verdicts; this record judges the contract, and the queue guard reads them.

④ Design judgment

  • A write path at this door that skips the verdict — none found. Each of the four writing service methods is reached from exactly one route and each of those routes passes catalogWrite; no non-row route resolves a writing method (read in ①). The datasource record's other door, PUT /meta/datasource/:name, asks the verdict itself. The migrate_credential metadata-type action targets this family's route and nothing else.
  • An operator wrongly refused — none found. The rung is derived from the platform anchors, not from manage_platform_settings or a position, and travels on the same resolution as the identity for both a key and a session. Pinned for a key on all four verbs under both walls. An operator whose anchor does not resolve is refused here exactly as at the /meta doors: parity, not a regression.
  • A refused PATCH that still mutates secrets or rebuilds a pool — none found. The refusal precedes resolve(), splitSecret and the service call; the admin service's secret writes and pool seams all sit inside updateDatasource. Pinned byte-equal store and secret ids, no pool built, and the serving pool still answering, under both walls, with the single control storing and rebuilding.
  • Posture read on the fallback host. The swallow-to-undefined is bounded by the kernel shapes in the tree (①). A future host shape that both lacks getServiceAsync and registers async service factories would read inert there; the docblock states the bound. Not a fix owed on this PR.
  • The in-memory driver mock in the PATCH rig. Judged in ③: no case in the suite reaches the driver, the mock's constructor throws so a reach would be loud, and the ledger row names it a mock replacement rather than a consumer, which is the disposition the census gate's own words ask for. The two walled PATCH cases still prove what they proved at b99ce15091: refusal before the service, byte-equal store and secret ids, no pool built, with the single control storing and rebuilding.
  • Rule level. The PR body and the changeset state the rule, the postures, the verbs, the answer and who moves; neither frames an exploit, names an advisory or narrates a path. The metadata-core comment, the two ADR anchors, the test comments and the ledger row's why do the same.
  • No must-fix. No should-fix owed on this PR; the two pre-existing items in ③ belong to the floor's own follow-up.

Implemented-by: claude/sec-hardening-r11
Reviewed-by: session_01L9qibvynRbCtMndW8EHNGc

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

席位核对

ACCEPT · 2026-11-06T17:33Z · head e8ccebeca528cbacef26dbd96e663f190cf2c59d

  • Contract review: PASS at this head, CONTRACT_REVIEW_TIER, record 6690677372 (isolated reviewer; narrowing face; floor = every non-prose path). No must-fix and no should-fix on this PR. Two pre-existing items at this door are left outside it, as the record says (the admission posture read on a host with no async service lookup; the authorization-store outage envelope off the shipped host). Both are carried as seat follow-ups.
  • Rule and ruling: the maintainer's ruling for this seat (2026-11-06, option A): under the walled tenancy postures, every row-writing verb of the datasource admin door asks the same walled-catalog verdict, with the same status and sentence, as the /meta door does for the same row. Reads and probes are unchanged.
  • Checks: 50 check-runs on this head, 38 success, 12 skipped, 0 failure, none pending. mergeable_state clean.
  • Seat commits after the dev's head: two test-only commits to fix two CI reds on earlier heads (check:test-source-alias, then check:driver-memory-census).
    • Two module-top warm loads, one vi.mock replacement for the in-memory driver (the suite never reaches it).
    • One mock-replacement row in the census ledger.
    • No source change. Both gates are green in the lane at this head.
  • Gates (dev, at b99ce15091; source unchanged since):
    • @objectstack/service-datasource 72 files / 1279 tests and @objectstack/metadata-core 23 files / 911 tests passed.
    • Typecheck exits 0 for both packages and their direct consumers.
    • dispatch-gates --commands: 102 derived, 102 run, 0 NOT-MEASURED.
    • Dogfood battery: 427 files passed, 3 skipped.
    • Ablations: removing the verdict at each of the four verbs reds that verb's legs (4/6/4/4), restore verified. Read at 56a424acf3, whose delta to this head in admin-routes.ts is one doc-comment paragraph (reviewer confirmed).
  • Shape: base main, no closing keyword (no card). Clause-②: no (narrowing), Metadata-protocol: none. @objectstack/service-datasource patch with the BREAKING banner and the ADR-0087 marker. The PR body and the changeset stay at rule level.
  • Governed: check-governed-merges.mjs --pr 31005: not governed; 426 changed lines ≤ 3000 ⇒ Tier S.
  • Landing authority: the maintainer's card answer in this seat's thread (2026-11-06, standing approval for security PRs with an at-tier PASS, seat ACCEPT and green checks at Tier S), and the maintainer's words 「你应该是使用 pm-dispatch 技能自主审核和落地 pr」. Next: relay pr_ready + automerge_enable.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 17:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit adab62a Nov 6, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-r11 branch November 6, 2026 18:08
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.

2 participants