Repository navigation
fix(approvals): the record-lock refusal names the record, not its primary key (#18153) - #18716
Conversation
…mary key (#18153) The per-row `RECORD_LOCKED` refusal is the one end-user-facing message of the four `lockedError` sites in this file — the console copies it into a toast verbatim — and it spelled the record `record '<id>' of '<apiName>'`, putting an opaque primary key and a machine identifier into user-facing prose on a path that reaches screenshots and support tickets. It now names the record the way its object declares it (ADR-0079 title pointer, object label), degrading to the label alone and then to "This record" — never back to the id. The id and the API name move to the console, where a support path still reads them. No read was added: the object label and title pointer come from the engine's in-memory registry, and the record is `ctx.previous`, the pre-image the engine has already read on every update shape measured. The three operator-facing refusals in the same file are unchanged and now pinned byte for byte; `RECORD_LOCKED` and its 409 are pinned in both directions. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
… sentence (#18153) Both asserted `record 'opp1' of 'opportunity'`. One WAS the defect's own assertion and now pins the label-named sentence plus the console demotion; the other used the id only as a discriminator between two competing approvals, so that discriminator moves to the console line, which still carries it. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
…cord-lock-message-user-facing
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e0d0874fa17d3c6b48ff57b9f856873b80bdb23 && git checkout 1e0d0874fa17d3c6b48ff57b9f856873b80bdb23
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a7e9a6600be9680090609c80e41374b637c0a6aa c0c490413390ddd7c8537fb40d7aea173df91f00 && git checkout -B drift-repro a7e9a6600be9680090609c80e41374b637c0a6aa && git merge --no-ff c0c490413390ddd7c8537fb40d7aea173df91f00
node scripts/docs-audit/affected-docs.mjs --json a7e9a6600be9680090609c80e41374b637c0a6aa
|
Fixes #18153
Clause-②: no
The per-row
RECORD_LOCKEDrefusal is the one end-user-facing message of the fourlockedErrorsites inlifecycle-hooks.ts— the console copies it into a toast verbatim— and it spelled the record
record 'ID' of 'API_NAME', putting an opaque primary key anda machine identifier into user-facing prose on a path that reaches screenshots, screen
recordings and support tickets.
It now reads:
degrading to
This Opportunity is locked …when the object declares no resolvable title,and to
This record is locked …when the registry is unreachable — never back to the id.The record id and the object API name are not deleted: they move to the console
(
logger.info, alongside the pending request's id), which is where a support path readsthem and where a screen recording does not.
The measurement the card asked for (P2)
Triage graded this as "a read this hook does not do today", and the dispatch order asked
for that to be measured rather than assumed. Measured: no read was added at all. Both
halves are already in hand at the refusal.
label, ADR-0079 title pointerengine.getSchema(object)ctx.previous, the pre-image the engine already readctx.previouswas measured against a realObjectQL+ a real sqlite driver on all fourupdate shapes — by-id,
updateManyData, predicatemulti, unscopedmulti. Every onedispatches this hook per row with
previousbound andinput.idset, so the title isfree on each.
previousis used only when its id really is the record the pending requestnames, so a dispatch that ever carried a different row cannot title the wrong record.
Two things deliberately not done, both stated in the code:
caller may not be allowed to READ — and gating rows in exactly that state is what this
lock exists for (the
sys_commenthas no record-level authorization: any org member reads and writes comments on records they cannot see #4630 rule the file already carries).payload_json. That column's whole discipline is that it is servedREDACTED per reader; lifting a field out of it into an error message routes around that.
Known degradation, declared: the title is read as a stored column, so an object whose
nameFieldpoints at a formula falls to theThis Opportunity is locked …form.Evaluating a formula title needs
evaluateFormulaField, which lives in@objectstack/objectql— a devDependency here — and promoting that to a runtime dependency is a bigger call than
this card carries. The degradation is safe in the direction that matters: it never reaches
for the id.
Controls, both mandatory ones
the two
PENDING_LOCK_LIMITcap messages and the unanswerable-intersection message.They are raised about a write SHAPE, not about a record, and naming the object's API
name there is the useful thing to say to whoever has to rescope that write. Pinned so a
later "harmonise the lock's messages" sweep goes red instead of folding them into the
end-user shape.
RECORD_LOCKEDand its 409 are pinned in both directions — the code and status areasserted to BE those values,
statusis asserted still absent (the A batch row'shttpStatusreads only.status, so two genuine 4xx populations ship a row with no status at all #8570 single-spellingpin), and the
CODE: messageenvelope is asserted to still be the envelope.Ablation — the new pin can fail
Run from the committed state, mutation proved on disk (both directions: deleted-text count
0, injected-text count 1, blob hash moved), restore proved by blob-hash equality plus an
empty
git diff HEAD.The three that reddened are exactly the user-facing assertions
(
the card's second row, verbatim, now carrying 409,names the record by its label and leaks neither the id nor the API name,degrades to the object label — never back to the id — when no title resolves).The
RECORD_LOCKED/409 control and the three operator-refusal controls stayed greenunder the mutation, which is what makes the ablation non-vacuous: it reddened the copy and
left the contract alone.
Verification
Measured at
c0c490413(this branch's head, after mergingorigin/main).pnpm --filter @objectstack/plugin-approvals test— 47 files / 771 tests passedpnpm --filter @objectstack/plugin-approvals typecheck— exit 0 (check:test-typecheck: OK)pnpm --filter '@objectstack/plugin-approvals^...' build— exit 0pnpm --filter @objectstack/spec build && check:generated— all 15 generated artifacts up to datepnpm lint(repo-wide,eslint . --no-inline-config) — exit 0 in 64s; run whole, sono narrowing argument is owed
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandswith no paths (merge-base derivation) — 62 families derived, 62 run, 0 NOT-MEASURED,
0 UNRUN, reconciled with
--rancarrying every exit code. Four initially exited 3(PREREQUISITE NOT MET, not failures):
check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtneeded a built repo, andcheck-plugin-teardown-shape --self-testneeded a commit this shallow clone lacked. Allfour re-ran green after
turbo run buildover the packages and a targetedgit fetch origin SHA.Two pre-existing pins re-pointed
approval-service.test.tscarried two assertions on the old sentence. One WAS the defect'sown assertion (
record 'opp1' of 'opportunity' is locked) and now pins the label-namedsentence plus the console demotion. The other used the id only as a discriminator
between two competing approvals — one locking, one opted out — so that discriminator moves
to the console line, which still carries it. Without that move the test would have passed
on a refusal raised by the wrong request, which is the exact confusion it exists to rule out.
Acceptance notes
Out of scope, noted, not filed:
packages/metadata-protocol/src/protocol.batch-row-driver-text.test.tsandprotocol.batch-row-http-status.test.tsconstruct their own syntheticRECORD_LOCKEDerror and import nothing fromplugin-approvals— verified, so they donot pin this producer and they stay green. Their docblocks describe that fixture as
MEASURED off
plugin-approvals'lockedError, and the message half of that descriptionis now stale; the
code/statusCode/ envelope half, which is what those filesactually assert, is unchanged. Comment-accuracy drift in another package, outside this
card's declared file surface. Successor: whoever next edits the batch-row error table,
which those two files and
protocol.ts(its copies near theRECORD_LOCKEDrow) share.Premises
branch point: the
lockedError(call opens at :392, its template string is at:393. Line numbers in this card were not trusted for anything else.
metadata-protocolpins aresynthetic doubles, so the blast radius stayed inside the declared file surface.
hotcrm). Not reachable and not claimed. The producer is what was verified.packages/specchanges. The one spec symbol used,resolveDisplayField(ADR-0079's single arbiter of "which field is the title"), is imported from the already
published
@objectstack/spec/data— a runtime dependency of this package — and nothingin
packages/specis edited.Generated by Claude Code