Skip to content

Commit 3f857e2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14059-governed-enqueue-precondition
2 parents efb35b0 + fa06ed5 commit 3f857e2

16 files changed

Lines changed: 1211 additions & 710 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): every `/data` exit and the approvals door now hand the caller the human half of a declared-code-prefixed message (#13095)
6+
7+
The 2026-08-29 ruling on #12975 made the by-id `/data` door strip the
8+
ADR-0111 `CODE:` prefix from the human-readable `error` string — one
9+
envelope semantics: `error` is human language, `code` is the machine token.
10+
`resolveErrorResponse`'s declared-4xx `passThroughStatus` arm is a second
11+
declared-4xx arm the ruling did not name, checked BEFORE the door it
12+
delegates to — so the same refusal read two ways depending on which route
13+
caught it: `PATCH /data/:object/:id` answered the bare localized sentence
14+
while `POST /data/:object/batch` (and every bulk/clone exit reporting
15+
through `handleRouteError`) and the record-share classified arm still
16+
shipped `FORBIDDEN: <sentence>`. Maintainer ruling 2026-08-31 (option 1):
17+
converge them.
18+
19+
On-wire changes, all subtractive on message text only, all 4xx, statuses
20+
and `code`/`declaredCode` fields untouched:
21+
22+
- `resolveErrorResponse`'s declared-4xx arm now applies the same
23+
declared-code-anchored strip (`withoutDeclaredCodePrefix`) the by-id door
24+
applies: a message opening with the producer's own declared `code`
25+
followed by a colon loses that prefix — and only that prefix. A message
26+
that is nothing but the prefix degrades to `Request failed`. This
27+
converges the `/data` batch/createMany/updateMany/deleteMany/clone exits
28+
and — because the record-share classified arm re-dresses the same
29+
classification — `GET/POST/DELETE /data/:object/:id/shares*` with them.
30+
- The approvals door's prefix strip is now anchored to the code the row
31+
answers instead of the blanket `/^[A-Z_]+:\s*/` regex (the shape #12975
32+
rejected): a sentence opening with a DIFFERENT `SCREAMING_SNAKE:` token
33+
than the answered code is no longer eaten.
34+
35+
Not moved, deliberately: a declared 4xx with NO `code` keeps its message
36+
verbatim (the token is nowhere else on the wire); a prefix that does not
37+
restate the declared code is left alone; declared-5xx prose withholding is
38+
unchanged; the share family's bare-`Error` prefix-idiom arm already
39+
stripped and is untouched; an empty-string message through the passthrough
40+
still ships as itself (that TYPE-keyed degrade is a standing pin this
41+
ruling did not move).
42+
43+
**Migration.** Consumers that parsed the `CODE:` prefix off the front of
44+
`error` (flat `/data` doors) or `error.message` (nested record-share
45+
envelope) on these routes must read the `code` field instead — it has
46+
carried the same token all along, with unregistered spellings demoted to
47+
the `declaredCode` sibling (#9232). The consumer census behind this change
48+
covered `objectstack`, `objectui` and `hotcrm` (re-run 2026-09-01, each
49+
scope with a positive control) and found zero consumers branching on the
50+
prefix. `objectstack-ai/cloud` was NOT MEASURED — it was unreachable from
51+
the implementing session — and is deliberately not reported as clean: the
52+
zero above is a statement about the three repos named, not about every
53+
deployment. An operator whose code parses the leading token off these
54+
routes' error text should locate and update those reads before upgrading.
55+
Per #13347's precedent an error-envelope shape change ships as `minor`
56+
with this note.

.claude/workflows/docs-accuracy-audit.js

Lines changed: 80 additions & 252 deletions
Large diffs are not rendered by default.

.github/workflows/lint.yml

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,17 +1871,19 @@ jobs:
18711871
- name: Template version-time rewriter self-test
18721872
run: pnpm check:template-version-sync
18731873

1874-
# #4851: the docs-accuracy-audit workflow carries its default scope inline
1874+
# #4851: the docs-accuracy-audit workflow's default scope is a generated list
18751875
# (a workflow script runs in a vm with no filesystem, so it cannot enumerate
1876-
# content/docs/ itself). Hand-kept, that list rotted in BOTH directions —
1877-
# 16 entries pointing at files that no longer existed after the
1878-
# protocol/objectos → protocol/kernel rename, and 48 existing docs missing
1879-
# from it — while every "FULL audit" run reported green, which is how the
1880-
# accuracy defects in #4781 and #4817 survived ~2 months. This regenerates
1881-
# the list from the filesystem and fails when the two disagree either way.
1882-
# It lives in this job deliberately: the change that breaks the list is a
1883-
# docs rename, so a `packages/**` paths filter would blind the gate to
1884-
# exactly its own failure mode.
1876+
# content/docs/ itself — the caller hands the list in). Hand-kept, that list
1877+
# rotted in BOTH directions — 16 entries pointing at files that no longer
1878+
# existed after the protocol/objectos → protocol/kernel rename, and 48
1879+
# existing docs missing from it — while every "FULL audit" run reported
1880+
# green, which is how the accuracy defects in #4781 and #4817 survived
1881+
# ~2 months. This derives the list from the filesystem and fails when it and
1882+
# scripts/docs-audit/handwritten-docs.json disagree either way; it also runs
1883+
# the workflow against stub agents to check the body still consumes what it
1884+
# is handed. It lives in this job deliberately: the change that breaks the
1885+
# list is a docs rename, so a `packages/**` paths filter would blind the gate
1886+
# to exactly its own failure mode.
18851887
- name: Docs-audit scope is derived, not hand-kept
18861888
run: pnpm check:docs-audit-scope
18871889

AGENTS.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,15 +301,15 @@ localStorage / auth gotchas.
301301
land, split the governed files into their own PR. **Those four lift only for an authorized approval pinned to the
302302
current head** (2026-08-27; guard 2026-08-28): APPROVED, by an account in `GOVERNED_APPROVERS`
303303
(`scripts/pm/check-governed-queue-guard.mjs`), `commit_id` = that sha — then the queue lands it, re-validating as
304-
it merges, and any later push expires it. Even a **pure-regeneration** PR requests its pinned approval proactively,
305-
before queueing — the queue-time leg installs no dependencies and never evaluates the byte-equality lift
306-
(2026-08-29). Unpinned, the maintainer's bypass direct merge (人工直合) is the only landing. ⛔ **No agent seat
307-
submits an approving review on a governed-surface PR, under any account** — an authorized account is
308-
agent-operated too. Nothing else substitutes: under #13 an accepted ADR *is* the decision, so merging one is the act
309-
of adopting a governance position — the one class of change about which "CI is green" carries no information at
310-
all (a thorough, fully-green ADR draft has been closed by the maintainer on demand grounds no gate could evaluate).
311-
The other surfaces are reserved for a reason of the same shape, spelled out in the audit header below; the published
312-
catalog also lands in codebases this repo cannot see.
304+
it merges, and any later push expires it. Hand-authored governed content needs that approval; a PR whose only
305+
governed paths are register rows the queue leg regenerates byte-exact clears with zero approvals (2026-09-01) — an
306+
uncertified recompute, drift or a hand-authored sibling keeps it governed. Unpinned, the maintainer's bypass direct
307+
merge (人工直合) is the only landing. ⛔ **No agent seat submits an approving review on a governed-surface PR,
308+
under any account** — an authorized account is agent-operated too. Nothing else substitutes: under #13 an accepted
309+
ADR *is* the decision, so merging one is the act of adopting a governance position — the one class of change about
310+
which "CI is green" carries no information at all (a thorough, fully-green ADR draft has been closed by the
311+
maintainer on demand grounds no gate could evaluate). The other surfaces are reserved for a reason of the same
312+
shape, spelled out in the audit header below; the published catalog also lands in codebases this repo cannot see.
313313

314314
**Already armed or queued when you read this?** ⚠️ Converting the PR back to **draft** is the only action that
315315
reliably removes it from the merge queue; `disable_pr_auto_merge` alone drops the arming but **not** queue

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6481,23 +6481,46 @@ export class SecurityPlugin implements Plugin {
64816481
// every later by-id write through the stored-row leg below. The residual
64826482
// envelope asymmetry is confined to exactly those shapes.
64836483
//
6484-
// [#8959, re-measured 2026-08-16] "Confined" is still NOT a publish-time
6485-
// bound today, though the reason has moved on from #8959's finding. #8772
6486-
// has since been RULED (2026-08-16, comment 5306089973) — it is no longer
6487-
// "open and unruled" — but the ramp it ordered has two code legs and
6488-
// neither has landed yet: Direction 2 (the authoring builder forces
6489-
// `required: true` under `controlled_by_parent`, #9138) is dispatchable
6490-
// now but not yet merged; Direction 1 (`relationship/master-detail-required`
6491-
// promoted `warning` → `error`, scoped to `controlled_by_parent`, #9139) is
6492-
// deliberately held for the **v18** boundary (`main` is still 17.x as of
6493-
// this note). So the lint measured by #8959 is unchanged: `master_detail`
6494-
// with no `required` still draws only `severity: 'warning'`, and
6495-
// `required` + `readonly` / `required` + `system` still draw nothing at
6496-
// all (`packages/lint/src/data-model-rules.ts`). A freshly authored detail
6497-
// therefore still reaches this branch and its `422` — the residue is a
6498-
// live, newly-authorable surface, not a shrinking legacy tail, until BOTH
6499-
// #9138 and #9139 land. Re-check this paragraph before trusting it; once
6500-
// both have landed it is the one that goes stale next.
6484+
// [#8959, re-measured 2026-09-01] "Confined" is now a PARTIAL publish-time
6485+
// bound: of the three shapes above, ONE is fenced at authoring time and
6486+
// two are not. #8772 was RULED (2026-08-16, comment 5306089973) and the
6487+
// ramp it ordered has two code legs, of which exactly one has landed.
6488+
// Direction 2 (#9138) IS merged: `ObjectSchema.create()` now runs
6489+
// `forceCbpMasterDetailRequired` (`packages/spec/src/data/object.zod.ts`)
6490+
// — under `controlled_by_parent`, a `master_detail` reference with
6491+
// `required` omitted is forced to `required: true`, and an explicit
6492+
// `required: false` is refused with a located error. Direction 1
6493+
// (`relationship/master-detail-required` promoted `warning` → `error`,
6494+
// scoped to `controlled_by_parent`, #9139) has NOT landed — that card is
6495+
// open and `pm:on-hold`, deliberately held for the **v18** boundary
6496+
// (`main` is still 17.x as of this note).
6497+
//
6498+
// ⛔ #9138 landing is NOT "the residue is gone". It narrows the FIRST
6499+
// shape only, and only on the builder path:
6500+
// • no `required` — no longer newly authorable through
6501+
// `ObjectSchema.create()`, but it survives on the raw
6502+
// `.parse()`/`.safeParse()` path, which #9138 left untouched ON
6503+
// PURPOSE so metadata already at rest keeps loading (its docblock
6504+
// says so). Measured: `safeParse` of a `controlled_by_parent` object
6505+
// takes both an omitted `required` and an explicit `required: false`.
6506+
// • `required` + `readonly` and `required` + `system` — STILL newly
6507+
// authorable through `ObjectSchema.create()` itself: the force
6508+
// returns early on `required === true` and never inspects
6509+
// `readonly`/`system`. It in fact MINTS them — a `readonly`/`system`
6510+
// master reference authored with no `required` comes out of
6511+
// `create()` as `required: true` plus the flag, which is exactly a
6512+
// shape `validateRecord` skips.
6513+
//
6514+
// And nothing warns on the way past: the lint measured by #8959 is
6515+
// unchanged — the predicate is still `def.required !== true` at
6516+
// `severity: 'warning'` (`packages/lint/src/data-model-rules.ts`), so the
6517+
// two flagged shapes draw NO finding at ANY severity and only the
6518+
// un-flagged one draws a warning. A freshly authored detail therefore
6519+
// still reaches this branch and its `422`: for the two flagged shapes
6520+
// straight through the builder, for the third through raw parse. It is a
6521+
// live surface, not a shrinking legacy tail. Re-check this paragraph
6522+
// before trusting it — it goes stale when #9139 lands, or when the
6523+
// builder force grows to cover the two flagged shapes.
65016524
//
65026525
// ⛔ [#9137] FREEZE NOTE — maintainer ruling on #8772, Direction 4,
65036526
// "immediately": until the two legs above both land, this `if` is the

packages/rest/src/error-response.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,11 +1762,44 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo
17621762
// means moving the STATUS this arm decided, which is the contract
17631763
// question this card was fenced away from — filed separately.
17641764
const businessMessage = sandboxBusinessMessage(error);
1765-
const safeMsg = businessMessage !== undefined
1766-
? truncateClientMessage(businessMessage)
1767-
: typeof error.message !== 'string'
1765+
// [#13095] The sentence this arm hands the caller is the HUMAN half
1766+
// only — the same #12975 rule `classifyDataError`'s declared-4xx arm
1767+
// applies, spread here by the 2026-08-31 maintainer ruling (option 1:
1768+
// one envelope semantics on every `/data` exit). This arm is checked
1769+
// BEFORE it delegates to `mapDataError`, so every route that reports
1770+
// through `handleRouteError` / `sendThrownError` (batch, createMany,
1771+
// updateMany, deleteMany, clone, and the record-share classified arm,
1772+
// which re-dresses this very answer through `classifiedRefusalAnswer`)
1773+
// was still shipping the ADR-0111 `CODE:` prefix the by-id door had
1774+
// stopped shipping — one refusal, two readings, decided by which
1775+
// route caught it.
1776+
//
1777+
// Anchored to the DECLARED code ({@link withoutDeclaredCodePrefix} —
1778+
// ⛔ never a SCREAMING_SNAKE pattern; that function's docblock carries
1779+
// the safety argument), and run BEFORE the bound for #12975's reason:
1780+
// the prefix is not text addressed to the caller, so it must not
1781+
// spend the caller's #5423 budget. A message that is nothing but the
1782+
// prefix has no human half to ship and degrades to 'Request failed',
1783+
// the sibling arm's rule travelling WITH the strip.
1784+
//
1785+
// ⛔ What deliberately does NOT converge here: a genuinely EMPTY
1786+
// string message still ships as itself. This arm's degrade is keyed
1787+
// on the TYPE, unlike `classifyDataError`'s sibling which also checks
1788+
// length — a standing pin (`rest-hook-refusal-message-parity.test.ts`)
1789+
// this card's ruling did not authorise moving. The 'Request failed'
1790+
// limb below therefore fires only when the STRIP emptied a non-empty
1791+
// message, never for a message that arrived empty.
1792+
const addressed = businessMessage !== undefined
1793+
? businessMessage
1794+
: typeof error.message === 'string' ? error.message : undefined;
1795+
const authored = addressed === undefined
1796+
? undefined
1797+
: withoutDeclaredCodePrefix(addressed, error);
1798+
const safeMsg = authored === undefined
1799+
? 'Request failed'
1800+
: authored.length === 0 && addressed !== undefined && addressed.length > 0
17681801
? 'Request failed'
1769-
: truncateClientMessage(error.message);
1802+
: truncateClientMessage(authored);
17701803
// [#9232] Narrowed, same as the three arms above.
17711804
return withDeclaredUserMessage(error, {
17721805
status: error.status,

0 commit comments

Comments
 (0)