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
rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope (#16143)
* fix(rest): the /references door answers both 501 refusals in one ADR-0112 envelope
`GET /api/v1/meta/:type/:name/references` could refuse in two ways and the two
answers agreed on neither the envelope nor the message:
A protocol cannot answer for this TARGET type (a `field`)
501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
B the resolved kernel has no `findReferencesToMeta` at all
501 {"error":{"code":"NOT_IMPLEMENTED","message":"…not available in this kernel"}}
Two facts were lost on A, and both of them are the operator's. The
PRESCRIPTION: A's message names the question that IS answerable ("Ask the
owning object instead: GET /api/v1/meta/object/<owner>/references"), which is
what keeps "the question was never asked" from being read as "nothing depends
on it" in front of an operator whose next click is a delete — ADR-0110 D3, and
`findReferencesToMeta` says the message is prescriptive for that reason in as
many words. On the wire it was replaced by "Internal server error". And the
`code`'s POSITION: `body.error.code` read on B and `undefined` on A, the very
dialect this route's own comment on the B branch warns against.
A reached the wire through `handleRouteError` -> `declaredServerFaultAnswer`,
which is correct for a server FAULT (#11718, #5582) and cannot see that a
producer-declared 5xx might be a deliberate REFUSAL whose message is authored
for the caller. Teaching the relay that distinction would change behaviour for
every producer-declared 5xx at every door; this takes the bounded half instead —
one door re-dressing one refusal in the dialect it already publishes. The
general question is handed back as its own finding.
The arm is keyed to a declared 501 carrying this route's own published refusal
code and a non-empty message, and the catch is scoped to the protocol call
alone, so neither `resolveProtocol` nor the `resolveExecCtx` seam can reach it.
A `sys_metadata` outage's 503 still propagates withheld, flat and logged.
`rest-server-meta-references-refusal-envelope.test.ts` drives both refusals on
one boot and pins the prescription and the `code` position together, with the
withheld-503 and unpublished-501 controls that keep the arm from reading as
"5xx prose is public now". The existing #9327 pin in
`rest-server-meta-read-org-scope.test.ts` reads the code through both dialects
on purpose and is green either way; its comment described the divergence as
standing, so it is rewritten rather than left falsified.
Fixes#15685
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* test(rest): the /references refusal pin names the missing prescription in its diff
`toContain` on an absent nested message fails with a matcher TYPE complaint
rather than with the finding — measured under ablation. `toEqual` +
`stringContaining` prints `undefined` against the expected sentence, which is
what a reader of a red run needs to see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* chore(rest): pin the new fixture's findOne and re-anchor the elevation census
Three gate-owned follow-ons to the refusal-envelope repair, none of them a
behaviour change:
- `check:engine-double-contract`: the new fixture's `findOne()` now opens with
`assertEngineFindOnePredicate` — a fake looser than `ObjectQL.findOne` is how
a dead REST route once shipped with a green suite — and the RETAINED ledger
records the new pin so it protects this file from here on.
- `check:system-context-census`: inserting the refusal arm and its helper moved
every elevation read site below them in `rest-server.ts` (+88 above the route,
+109 below it). Ten anchors in `content/docs/permissions/system-context.mdx`
re-anchored by the gate's own `--fix`; pure line rot, no row added, removed or
re-worded.
Part of #15685
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* test(rest): record the execCtx census mention the refusal arm's comment adds
77 -> 77 sites / 99 -> 100 mentions. The `/references` refusal arm adds ONE
prose mention and NO call site: its comment records that the arm's catch is
scoped to the protocol call alone, so the `resolveExecCtx` seam above it cannot
reach the arm whatever that seam declares — the safety claim the narrow catch
rests on. The site count not moving is this two-number control working.
Part of #15685
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
---------
Co-authored-by: Claude <noreply@anthropic.com>
`GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it.
6
+
7
+
The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message:
8
+
9
+
```
10
+
A the protocol cannot answer for this TARGET type (a `field`)
11
+
501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
12
+
B the resolved kernel has no `findReferencesToMeta` at all
13
+
501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}}
14
+
```
15
+
16
+
A now answers in B's shape, carrying the producer's own sentence:
17
+
18
+
```
19
+
501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}}
20
+
```
21
+
22
+
Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `<object>.<field>` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator.
23
+
24
+
Two consequences for a caller:
25
+
26
+
-`body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered.
27
+
- Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+4-4Lines changed: 4 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -64,7 +64,7 @@ not on any flag.
64
64
## How the flag is set
65
65
66
66
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67
-
cannot set it (`packages/rest/src/rest-server.ts:1651`, `:1680`), and neither
67
+
cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither
68
68
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
69
69
written by internal callers only, as an option on the engine call:
70
70
@@ -103,7 +103,7 @@ that silently does not happen.
103
103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller |`stdio-data-bridge.ts:250`|
104
104
| 15 |**Read-audit rows are not written**| plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one |`read-audit.ts:556`|
105
105
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads |`payload-redaction-middleware.ts:115`|
106
-
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request |`rest-server.ts:1683`|
106
+
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request |`rest-server.ts:1771`|
107
107
108
108
### 2. Write pipeline and data integrity
109
109
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158
158
|:--|:---|:---|:---|:---|
159
159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes |`action-execution.ts:138`|
| 50 |`manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability |`domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529`|
161
+
| 50 |`manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability |`domains/meta.ts:471`, `:874`, `rest-server.ts:5319`, `:6766`, `:7014`, `:7445`, `:7638`|
162
162
| 51 | The shared metadata-write verdict itself returns `allowed`| metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined |`meta-write-capability.ts:134`|
163
163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId`|`domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:543`, `external-datasource-routes.ts:302`, `package-routes.ts:97`|
164
164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user |`domains/mcp.ts:61`|
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199
199
| "It preserves a supplied `updated_at` / `updated_by`" |**No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only |`field.zod.ts:1590` (#3493 / #6640) |
200
200
| "It stamps `created_by`" |**No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error |`runtime-identity.ts:280`–`281`|
201
201
| "It bypasses every guard" |**No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one |`last-admin-guard.ts:299`|
202
-
| "A client can request it" |**No.** Never settable from inbound HTTP or from an action body |`rest-server.ts:1651`, `:1680`; `domains/actions.ts:414`|
202
+
| "A client can request it" |**No.** Never settable from inbound HTTP or from an action body |`rest-server.ts:1739`, `:1768`; `domains/actions.ts:414`|
0 commit comments