Skip to content

Commit 7d7ca6c

Browse files
os-litantclaude
andauthored
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>
1 parent 07f40e5 commit 7d7ca6c

8 files changed

Lines changed: 463 additions & 26 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
`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.

content/docs/permissions/system-context.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`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
6868
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 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` |
104104
| 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` |
105105
| 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` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 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` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 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` |
162162
| 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` |
163163
| 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` |
164164
| 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.
199199
| "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) |
200200
| "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` |
201201
| "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` |
203203

204204
---
205205

packages/rest/src/error-response.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,14 @@ function armSentence(error: any): unknown {
397397
* `statusCode` rather than blocking it, which is what makes better-auth's
398398
* `APIError` (`{ statusCode: 403, status: 'FORBIDDEN' }` — the status field is a
399399
* STRING there) resolve to the status it meant instead of to nothing.
400+
*
401+
* [#15685] Exported so the `/meta/:type/:name/references` door can ask THIS
402+
* question — "did the producer declare a status, in either spelling" — instead
403+
* of re-deriving it beside its own refusal arm. A read, not a policy: the
404+
* export moves no wire byte, and `error-response.ts` is not part of
405+
* `@objectstack/rest`'s package entry, so nothing published changes either.
400406
*/
401-
function declaredHttpStatus(error: any): number | undefined {
407+
export function declaredHttpStatus(error: any): number | undefined {
402408
const declared =
403409
(typeof error?.status === 'number' ? error.status : undefined) ??
404410
(typeof error?.statusCode === 'number' ? error.statusCode : undefined);

packages/rest/src/execctx-consumer-census.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,17 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
392392
// `enforceAuth` was measured NOT to be the repair). A mention count
393393
// that tracked the site count exactly would be measuring one thing
394394
// twice.
395+
//
396+
// [#15685] 77 → 77 sites / 99 → 100 mentions. The `/references` door's
397+
// refusal arm added ONE prose mention and NO call site: its comment
398+
// records that the arm's catch is scoped to the protocol call alone, so
399+
// the `resolveExecCtx` seam above it cannot reach the arm whatever that
400+
// seam declares. That is the safety claim the narrow catch rests on, so
401+
// naming the seam is the point of the sentence — and the sentence
402+
// moving only the mention count is this control working: a site was not
403+
// added, and the number that tracks sites did not move.
395404
expect(SITES.length).toBe(77);
396-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(99);
405+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(100);
397406
});
398407

399408
it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => {

packages/rest/src/rest-server-meta-read-org-scope.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,16 +1121,25 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', ()
11211121
// reads as "the question was never asked", so a scope repair that
11221122
// moved either would be moving the destructive-action clearance.
11231123
//
1124-
// ⚠️ The code is read through BOTH refusal dialects on purpose.
1125-
// Measured on this boot, the two 501s this route can answer do not
1126-
// agree: the missing-method branch hand-builds the ADR-0112 NESTED
1127-
// `{ error: { code, message } }`, while the protocol-raised
1128-
// unanswerable-target refusal reaches the wire as the FLAT
1129-
// `{ error: 'Internal server error', code }` — the prescriptive
1130-
// "ask the owning object instead" message scrubbed. That is a
1131-
// finding of its own, filed as #15685; it is NOT this card's
1132-
// subject, and reading both keeps this pin measuring the thing it
1133-
// is about.
1124+
// ⚠️ The code is read through BOTH refusal dialects on purpose,
1125+
// and the reason CHANGED with #15685 — so the sentence is rewritten
1126+
// rather than left standing as a falsified one.
1127+
//
1128+
// It used to accommodate a real divergence: the missing-method
1129+
// branch hand-built the ADR-0112 NESTED `{ error: { code, message } }`
1130+
// while the protocol-raised unanswerable-target refusal reached the
1131+
// wire as the FLAT `{ error: 'Internal server error', code }`, its
1132+
// prescriptive "ask the owning object instead" message scrubbed.
1133+
// #15685 closed that: both exits now answer the nested envelope, and
1134+
// `body.error.code` reads the same way on each.
1135+
//
1136+
// The tolerant read STAYS, deliberately. The envelope and the
1137+
// message are pinned — positionally, and on both refusals at once —
1138+
// by `rest-server-meta-references-refusal-envelope.test.ts`, which
1139+
// is where a regression in either belongs. What THIS pin measures is
1140+
// that a SCOPE repair moves neither the code nor the status, and
1141+
// reading the code wherever it sits is what keeps it measuring that
1142+
// and not a second copy of the envelope contract.
11341143
const refused = await b.references('field', 'account.owner');
11351144
const body = refused.body as any;
11361145
const observed = refused.thrown

0 commit comments

Comments
 (0)