You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a3bcbcf
Browse filesBrowse the repository at this point in the historyBrowse files
Fixes#21908
Clause-②: no (narrowing)
## Summary
ADR-0096 D5 strict mode, the deny round of #21908. A data-engine context
that carries no principal (no user id, no position, no permission set)
and is not a system context is now **refused** at every place
`plugin-security` used to hand it through. The published contract
sentence on `ChatWithToolsOptions.toolExecutionContext`
("unauthenticated (RLS-on, sees-nothing)") now holds.
Before the deny, every producer of such a context that a run or a static
read found was closed inside this claim: it carries the caller's
principal or the explicit system opt-in (`isSystem: true`) inside its
owning code. Breaking for in-process code (the `!` banner); the
changeset carries the migration and an ADR-0087 disposition. The at-tier
contract review is owed before the queue.
The ADR-0096 D5 dated note is its own Tier H draft PR, on
`claude/issue-21908-adr-0096-d5-note`, for the maintainer's approval.
## What changes, by class
The predicate is `isPrincipalLessContext(context) && !isSystem`, the one
the hand-off already used. These are the six sites, at `origin/main`
`0e9371f0` positions:
| Site (`security-plugin.ts`) | Before | After |
|---|---|---|
| engine middleware (near `:2616`) | `return next()`: no CRUD gate, no
RLS, no field mask, no tenant wall | throws `PermissionDeniedError`
(`403 PERMISSION_DENIED`) for every verb, before the operation runs |
| `getReadFilter` (near `:5875`) | the sharing predicate alone | the
deny filter (zero rows) |
| `resolveProjectionFieldMask` (near `:6126`) | the full field set | no
early answer: the zero-set field answers, like any caller that resolves
no set |
| `canReadObject` (near `:6299`) | `true` | `false` |
| `canWriteObject` (near `:6585`) | `true` | `false` |
| `canExport` (near `:6688`) | `true` | `false` |
- **Unchanged:** a system context is admitted everywhere.
- **Unchanged:** a context that carries a principal without a user id is
decided by what it carries. That covers a named permission set, the
guest principal, and the public-form grant.
- **The gates that run before the refusal keep their own answers:**
package-managed, system-row, curated-capability, audience-anchor,
engine-owned and delegated-admin.
**Why the five service sites return rather than throw.** The ruling's
error class is the engine-level refusal, and the middleware throws it.
The published `ISecurityService` contract has `canReadObject`,
`canWriteObject` and `canExport` *return* `false`, and has
`getReadFilter` *return* the deny filter. Each of them now gives a
principal-less context the deny it already gives a caller that carries a
principal and resolves no set (ADR-0056 D2). The field projections are
field answers, not admission answers. The contract text for
`getReadableFields` names the answer for "a non-system caller who
resolves no permission set", and that answer now applies to this context
too. Object admission is refused by the middleware and the three probes.
## Producer census (H1), by class, position and function
**Method.**
- A temporary probe recorded the call stack at every principal-less exit
while the deny was in place. It was never committed; it was reverted,
and the blob was proven equal to HEAD.
- It ran over the full dogfood suite (10 shards, 239 files) and over
every unit file in 22 packages that composes the security plugin (about
90 files).
- A static sweep covered every `sys_*` engine call in production source
that passes no context.
- After the fixes, a real boot was driven, with a member's first query
and the admin authoring doors.
| Class | Position | Function | From a request | Closure |
|---|---|---|---|---|
| production, measured | `metadata-protocol` `protocol.ts` |
`promoteDraftForPublish` (the draft read) | yes: the publish door,
behind its authorization | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`resolveDraftOrgScopeForPublish` (both probes) | yes: an
organization-scoped publish | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`resolveMetaItemOrgScope` (both history probes) | yes: the
organization-scoped revert path | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`auditMetaItem` | yes: the audit door | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`diffMetaItem` (the history read and the current-row read) | yes: the
diff door | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`listCommits`, `revertCommit`, `rollbackToPackageCommit` | yes: the
commit doors | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`metaTypeNamespaceExists`, `migrateStoredMetadata` | yes: the authoring
and migration doors | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`deleteMetaItem` legacy path (read and delete) | yes: the code-only
delete on a control-plane kernel | explicit system opt-in |
| test harness | `qa/dogfood` | the federated-fixture test (context
passed as the options argument) | no | carries `{ context }` |
| test harness | `qa/dogfood` | `package-first-authoring` (a direct
store insert) | no | system opt-in |
| test harness | `runtime` | `standalone-stack-seeder-declaration-copy`
(context-less reads) | no | system opt-in |
| test harness | `runtime` | the `share-links-enforcement-context`
engine double (dropped the trailing read options) | no | honours the
trailing context, as `mergeReadContext` does |
**Not producers today.** `loadMetaFromDb` and
`reportUnhydratableOrgScopedRows` read context-less at boot. They run
inside ObjectQL's `start()`, and plugin-security depends on ObjectQL, so
they run before the security middleware is registered. Noted, not
edited.
No producer was found outside `plugin-security`, `protocol.ts` and the
test harnesses. The static sweep's other 25 hits all go through a
system-context wrapper or a context-carrying options object, or are
comments. Every edit uses only the declared opt-in; nothing guesses a
boundary. Each moved call is a `sys_metadata*` store read, or the legacy
delete of a `sys_metadata` row. None of the six gates the middleware
runs before the refusal keys on those objects, so the opt-in changes
nothing they decide.
**The dispatcher's identity-resolution fault class** (the `domain:cli`
seat's note) is closed by this deny by construction. It is pinned below,
and `http-dispatcher.ts` is not edited.
## Pins
**The ruling's three pins:**
- **A principal-less context is refused on read and write:**
`plugin-security` `principal-less-strict-mode.test.ts`. It covers every
spelling: no context, an empty one, empty arrays, `isSystem: false`, a
tenant id alone, and provenance only. The middleware answers
`PERMISSION_DENIED` / 403 for all seven verbs before the operation runs,
the probes answer `false`, and the row scope is the deny filter.
- **An explicit system context is unchanged:** the same file, with and
without a principal.
- **The dogfood suite is green under the deny:** see Evidence.
**Further pins:**
- **The hand-through pins became refusal pins:**
`zero-set-deny-baseline`, `zero-set-masking`,
`zero-set-capability-fold`, `metadata-unresolvable-posture`,
`predicate-related-read-tenant-scope` and `security-plugin.test.ts`'s
row-scope case.
- **The deny is keyed on carrying no principal:** a user-less named set,
the guest principal and the public-form grant are each decided by what
they carry.
- **The `resolveRequestScope` fault class is now denied:** `runtime`
`dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts`,
over the real dispatcher plugin and the real security middleware. The
engine is handed no principal, the store is never reached, and the
caller gets 403 with nothing of the rows. Its two controls: an anonymous
request with no fault runs as the guest (the posture of #22147,
unchanged), and a granted signed-in caller is served.
- **The guest posture is unchanged at a real boot:** #22177's
`declarative-endpoint-anonymous-guest` pins stay green under the deny.
- **The composed Q2 pin:** `qa/dogfood`
`owner-of-private-object-under-strict-mode`. An `owner_of:` audience on
a `private` object reaches its owner's inbox, and nobody else's, end to
end. In the same composition, a principal-less read of that object is
refused.
- **The protocol producers:** `metadata-protocol`
`protocol.platform-store-system-opt-in.test.ts`, deny-round block. Each
producer is driven, and every engine call it issues must carry
`isSystem: true`.
## Ablations
Each ablation ran through `scripts/ablation-replace.mjs` (wrap mode,
plus a shell trap). Every mutation landed (anchor 1 → 0, blob changed),
and every restore was proven (blob == HEAD, `git diff HEAD` empty). The
dist legs were proven with `ablation-dist-preflight` (marker present
while live, `--absent` after the restore rebuild, tree clean).
| # | Mutation | Red |
|---|---|---|
| A1 | the middleware refusal replaced by the old `return next()` (dist
leg) | plugin-security 13 of 42; runtime fault-class pin 1 of 3 (the
store was reached) |
| A2 | `getReadFilter` answers no scope for the class | 8 of 305 |
| A3 | `canReadObject` admits the class | 7 of 305 |
| A4 | the diff door's history read loses the opt-in | 1 of 7 |
| A5 | the promotion's draft read loses the opt-in | 1 of 7 |
| A6 | `resolveOwnerOf` loses its opt-in (service-messaging, dist leg) |
the composed Q2 pin 1 of 1 (0 recipients) |
## Evidence
Final head `454d0e89b9`: `origin/main` `8cbe255ef6` merged at
`6681b216e1`, then a docblock-only test commit. All runs were made under
the shared verify lock, so wall times are shared-box readings.
**On the merged tree (`6681b216e1`):**
- plugin-security: 180 files, 3800 passed, 45 skipped.
- metadata-protocol: 221 files (+3 skipped), 28280 passed.
- runtime: 339 files, 5490 passed, 19 skipped.
- The composing unit files of the packages `main` touched (rest, cli,
lint, platform-objects, plugin-approvals, service-analytics,
organizations, spec): all green.
- **dogfood, the whole suite in 10 shards: 223 files, 1754 passed, 9
skipped.**
- typecheck exits 0 for plugin-security, metadata-protocol, runtime and
dogfood. `tsc --listFiles` shows metadata-protocol's program compiles
the edited pin.
**Before the merge (`8248ceb97f`):** objectql 381 files / 7525 passed,
and core 81 files / 2240 passed. Neither package is touched by `main`'s
incoming commits or by this diff.
**Real boot, showcase `--fresh`, on the merged tree:**
- An anonymous request answers 401.
- The admin signs in and invites a member. The member signs up, and the
member's first data query answers 200, as do the member's object read
and inbox.
- The admin's draft save, publish, diff, audit, history, delete and
package-commits doors all answer 200.
- The server log carries zero strict-mode refusals.
**Gates:** `dispatch-gates --commands` derived 73 families at
`454d0e89b9`. All 73 ran and exited 0. `--ran`: "73 derived famil(ies)
accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded
an exit code and none of them is 3)". Selected verdict lines:
- `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition.`
- `✓ This diff introduces no \`major\` bump.`
- `check-nul-bytes: OK`
- `check-engine-double-contract: OK`
- `check-test-source-alias OK`
- `check:cross-package-test-inputs OK`
**Lint, a proven narrowing (CI runs the full `pnpm lint`):**
- The population read from `eslint.config.mjs` is
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` plus the `packages/**` blocks.
- `eslint --no-inline-config --format json` over the 16 changed `.ts`
files at `454d0e89b9` gives 16 files, 0 errors, 0 warnings.
- The config enables no type-aware linting (no `parserOptions.project`,
no typed rules), so no untouched file's verdict can move.
`check-governed-merges --branch`: 0 of 17 paths on the register; 1083
changed lines.
## Acceptance notes
- **The spec's `ISecurityService` prose still describes the hand-off.**
Four TSDoc sentences on `getReadFilter`, `canExport` and `canReadObject`
in `packages/spec/src/contracts/security-service.ts` say a context with
no principal "is admitted" or "keeps the scope". `packages/spec` is
outside this claim, so the sentences are not edited here. The report
asks the seat how to route them.
- **Two of the producers closed here are outside the cross-lane
declaration.** The `domain:engine` declaration on #6367 names
`promoteDraftForPublish`. The census found the other `protocol.ts`
producers listed above in the same file: the same class, with the same
mechanical opt-in. They are closed here and named for the seat, so the
declaration can be amended.
- **A test-side spelling trap.** Passing an `ExecutionContext` variable
as the options argument of an engine verb compiles, and silently drops
the context. One fixture did this. Under strict mode it is now a loud
403, no longer a silent hand-through.
- **What the census cannot see.** The static sweep reads `sys_*` object
names spelled as literals; a call whose object name is a variable is
covered only by the runs.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode)
7
+
8
+
Clause-②: no (narrowing)
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime admission: the security middleware refuses an engine operation whose context names no user, no position and no permission set and is not a system context, which it used to hand through. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->
11
+
12
+
**BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes.
13
+
14
+
**What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way:
15
+
16
+
- the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs;
17
+
-`canReadObject`, `canExport` and `canWriteObject` answer `false`;
18
+
-`getReadFilter` answers the deny filter (zero rows).
19
+
20
+
It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set.
21
+
22
+
**What is unchanged.**
23
+
24
+
- A system context (`isSystem: true`) is admitted everywhere, as before.
25
+
- A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant.
26
+
- An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal.
27
+
28
+
**What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either
29
+
30
+
- pass the caller's execution context, so the caller's own permissions decide; or
31
+
- for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`.
32
+
33
+
The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both.
34
+
35
+
`@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes.
0 commit comments