Skip to content

[finding] RECORD_LOCKED rejection toast surfaces the internal record id and the object API name to end users #18153

Description

@yinlianghui

Finding, not a claim on anyone's queue — filed by the video-production lane. Not taking this on.

What we observed

Attempting to move a record that is locked by an approval process produces a rejection toast whose text includes the internal record id and the object API name verbatim.

Two problems, in increasing order of seriousness:

  1. It's not user-facing language. An end user reading it learns an opaque id and an API identifier, neither of which helps them understand that an approval has locked the record.
  2. It's a disclosure surface. Internal identifiers in a toast are the kind of string that ends up in screenshots, screen recordings and support tickets. We hit exactly that: this toast is on the deny-path of the interaction our promo film demonstrates, so any mis-click during a take would have put internal ids on camera. We had to design the recording around never triggering it.

Environment

  • hotcrm main @ c716a2c (17.4.0), OS_PRODUCT_STAGE=ga
  • Opportunity kanban; drag a record whose approval_status is pending

What would resolve it for us

A user-facing message that names the record by its label and says an approval has it locked — with the id/API-name detail moved to the console or an expandable "details", not the toast body.

Context: promo-video ticket steedos-labs/video-studio#356 (private repo; the finding above is self-contained).

Activity

  1. self-assigned this
    on Sep 17, 2026
  2. huangyiirene commented on Sep 17, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 1
    Session: session_01QGMBhvUoyD8t5zY8xHQhnP
    Branch: claude/issue-18153-record-lock-message-user-facing
    Worktree: objectstack-issue-18153
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/plugins/plugin-approvals/src/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgment tier — cited from THIS dispatch's node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/plugins/plugin-approvals/src/lifecycle-hooks.ts at commit 6dfa3ea: "no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled … floor sonnet · default opus · ceiling fable". ⭐ Default tier, ⛔ explicitly not the floor: triage's own grading says the real work is a read this hook does not do today, ⛔ not a string swap — that is a design call, not a mechanical edit.
    Clause-②: no
    Thread-read: 5712580827
    Serial constraints cleared: none — measured at 2026-09-17T15:13Z, ⛔ not recalled. (a) This lane's only other pm:dispatched card is #16506, assignee zhuangjianguo, surface plugin-email ⇒ disjoint, ⛔ not this seat's card. (b) All 23 open PRs intersected file-by-file against plugin-approvals/src/lifecycle-hooks: 0 hits — control: the same intersector reports #18319 at 92 files and #17076 at 100 ⇒ instrument reading, ⛔ not returning empty. (c) Sibling cards dispatched in THIS batch — #18368 (service-settings) and #18110 + #18555 (service-automation) — are file-disjoint. (d) The patrol anchor's H17 restart-trigger file index names plugin-sharing, plugin-security, plugin-auth and client/runtime paths ⇒ 0 intersection with plugin-approvals. (e) ⛔ No sibling card pins behaviour this card asserts.

    Premise re-verification owed before any edit

    • ⚠️ Two different readings of the producer line exist and they disagree by one. Triage reads lifecycle-hooks.ts:392; this seat reads :393 on origin/main 6dfa3ea via git grep -n … origin/main. ⛔ Neither is authoritative — re-take it, and treat the disagreement as proof that line numbers in this card are ⛔ not to be trusted.
    • ⭐ Triage named a real constraint that is easy to get wrong, and it is carried into the dispatch order verbatim: the same file has three other lockedError sites (:196 / :269 / :315) which are operations-facing boundary messages where naming the object IS useful. ⛔ They are not in scope and must not be changed with the one at :392/:393.
    • ⚠️ git log origin/main -- packages/plugins/plugin-approvals/src/lifecycle-hooks.ts returns only the graft-boundary commit e46ac0f, which this checkout's own check-harness-current.mjs reports as parentless and diffed against the EMPTY tree ⇒ ⛔ a shallow-clone artefact, NOT evidence of a recent touch. Treat this file's recent history as UNREAD.
    • ⛔ hotcrm is NOT reachable from this session (GitHub scope: objectstack-ai/objectstack only), and neither is the private steedos-labs/video-studio#356 the card references. The "what the toast says verbatim" reading is the filing lane's, accepted as declared and ⛔ not re-verifiable here — but the producer is in this repo and IS verifiable, which is why the card lands in this lane at all.

    Dispatched by the domain:services PM seat (objectstack#6021) at 2026-09-17T15:13Z. ⛔ The dev leaves no second claim — this comment is the identity.


    Generated by Claude Code

  3. huangyiirene commented on Sep 17, 2026

    @huangyiirene
    Collaborator

    os-dev-report

    {
      "issue": 18153,
      "status": "done",
      "branch": "claude/issue-18153-record-lock-message-user-facing",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18716",
      "session": "session_01QGMBhvUoyD8t5zY8xHQhnP",
      "premise_still_valid": true,
      "summary": "The one end-user-facing lockedError site in lifecycle-hooks.ts now names the record by its object label plus its ADR-0079 title instead of `record 'ID' of 'API_NAME'`, degrading to `This Opportunity is locked ...` and then `This record is locked ...` and never back to the id; the record id, the object API name and the pending request id move to the console via logger.info. P2 measured and partly refuted: NO read was added -- the object label and title pointer come from the in-memory registry (engine.getSchema) and the record is ctx.previous, the pre-image the engine already binds on all four update shapes (by-id, updateManyData, predicate multi, unscoped multi), each measured against a real ObjectQL plus a real sqlite driver. A SYSTEM_CTX read of the record and the payload_json snapshot were both rejected on disclosure grounds and that is written into the code. Declared degradation: a formula-backed nameField is not evaluated (evaluateFormulaField lives in objectql, a devDependency here), so such objects fall to the label-only form.",
      "gates": "dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with NO paths (merge-base derivation, per the PM correction and the role file) at HEAD c0c490413: 62 families derived, 62 run, 0 NOT-MEASURED, 0 UNRUN -- reconciled with --ran carrying an exit code for every one ('a DERIVED zero -- all 62 recorded an exit code and none of them is 3'). Roster byte-identical to the pre-merge derivation. Four initially exited 3 = PREREQUISITE NOT MET, not failures, and all four re-ran green: check:dual-build-cjs-loads / check:i18n / check:type-check-debt needed a built repo (fixed by `turbo run build --filter=./packages/* --filter=./packages/*/*`), and check-plugin-teardown-shape --self-test needed a fixture commit this shallow clone lacked (fixed by `git fetch origin 621a487607881c66b2899b7e3477115229a156b4`). Repo-wide `pnpm lint` (eslint . --no-inline-config) run WHOLE, exit 0 in 64s, so no narrowing argument is owed. packages/spec check:generated: all 15 generated artifacts up to date. CI convergence not awaited, per contract.",
      "tests": "pnpm --filter @objectstack/plugin-approvals test -> 47 files / 771 tests passed (exit 0, under os-verify-lock, VERDICT command-exit 0). pnpm --filter @objectstack/plugin-approvals typecheck -> exit 0, 'check:test-typecheck: OK ... 8 file(s) / 324 error(s) / 27 pinned signature(s)' (debt ledger unmoved). New pin file record-lock-user-facing-message.test.ts: 4 user-facing cases + 3 CONTROL cases for the operator-facing refusals, byte for byte. ABLATION (run from the committed state): producer reverted to the old sentence -> exit 1, 3 failed / 8 passed; restored -> exit 0, 11 passed. On-disk mutation proof in BOTH directions before reading any result: deleted-text grep count 0, injected-text grep count 1, blob 334a20ac -> 5f833456. Restore proved by blob-hash equality back to 334a20ac AND an empty `git diff HEAD`, not by an exit code; the script carried `trap restore EXIT INT TERM` with absolute paths off `git rev-parse --show-toplevel`. Direction observed = the predicted one (RED), and non-vacuous: the three that reddened are exactly the user-facing assertions, while the RECORD_LOCKED/409 control and all three operator-refusal controls stayed GREEN under the mutation. No dist preflight was owed: the pin imports ./lifecycle-hooks.js relatively, so the mutation was source-resolved, not dist-resolved.",
      "line_budget": "No skills/** path in the diff, so no published-skill line ratchet applies and none was spent. Diff vs merge-base f8eaf6704: +510 / -7 over 5 files -- lifecycle-hooks.ts +136 (of which ~90 are the module-docstring section recording the measurement and the two rejected designs), record-lock-user-facing-message.test.ts +263 (new), approval-service.test.ts +52, record-lock-batch-row-status.integration.test.ts +23, changeset +43.",
      "deviations": [
        "Gate derivation: the dispatch order said `--commands` with a changed-file list; the PM corrected it mid-task to no paths, which is also the role file's standing rule. Followed the corrected form; the paths form was never used for a reported roster.",
        "Merged origin/main (f8eaf6704 -> c0c490413) into the branch before opening the PR -- AGENTS.md Multi-agent discipline section 10, not named in the dispatch order. Clean merge, no os-regen deferral, no overlap with my files. packages/spec moved on main's side, so spec was rebuilt and check:generated re-run before anything else was trusted.",
        "Four gates first answered exit 3 (PREREQUISITE NOT MET). Rather than report them NOT MEASURED I paid the prerequisite (a turbo build over packages, and one targeted `git fetch origin SHA` for a pinned self-test fixture a shallow clone lacked) and re-ran all four to exit 0.",
        "The container restarted and SIGKILLed a verification run mid-flight (exit 137). The pushed tree was intact (HEAD == origin, working tree clean); every verification was then re-run from scratch on the merged tree rather than carrying a pre-restart green forward.",
        "Two PRE-EXISTING tests in approval-service.test.ts asserted the old sentence and had to be re-pointed. Both are inside the declared file surface, but they are beyond the literal 'one site' reading, so declaring them: one WAS the defect's own assertion; the other used the id purely as a discriminator between a locking and an opted-out approval, and that discriminator moved to the console line, which still carries it. No operator-facing message was touched.",
        "One import was added to lifecycle-hooks.ts, which previously had none: resolveDisplayField from @objectstack/spec/data (ADR-0079's single arbiter of the title pointer), a package already declared as a runtime dependency. Zero packages/spec edits."
      ],
      "files_changed": [
        "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts",
        "packages/plugins/plugin-approvals/src/record-lock-user-facing-message.test.ts (new)",
        "packages/plugins/plugin-approvals/src/approval-service.test.ts",
        "packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts",
        ".changeset/18153-record-lock-message-user-facing.md"
      ],
      "mcp_calls": "0 -- no MCP GitHub tool of any kind was called, read or write.",
      "api_writes": "2 REST proxy writes, exactly the dispatched budget: POST /repos/objectstack-ai/objectstack/pulls (draft, HTTP 201, PR 18716) and POST /repos/objectstack-ai/objectstack/issues/18153/comments (this report). No label write, no assignee write, no PATCH. Separately, 4 `git push` operations on the feature branch (empty-branch routing probe, 2 commits, 1 merge). All GitHub reads were GET.",
      "open_questions": [
        {
          "question": "A formula-backed `nameField` is not evaluated, so on those objects the toast degrades to 'This Opportunity is locked ...' rather than naming the record. objectql's record-title.ts reports that 4 of 5 measured objects title by formula. Is the label-only form good enough for this card, or should a follow-up card carry the formula leg?",
          "options": [
            "A -- ship as is and file nothing; the acceptance criterion (no id, no API name, an actionable name) holds unconditionally and the degradation never reaches for the id.",
            "B -- ship as is and let the PM file a follow-up for the formula leg, which needs either a runtime dependency on @objectstack/objectql (4 plugins already have one) or a title accessor reachable from a plugin without it.",
            "C -- widen this PR to promote @objectstack/objectql to a runtime dependency of plugin-approvals now."
          ],
          "recommendation": "B. The degradation is safe and declared in the code, the changeset and the PR, so A under-records a real gap; C is an architectural move on a P2 copy card and is exactly the widening P3 warns about. Filing it separately keeps this diff at the size the card describes. I did not file it myself: it is an enhancement, not a reproducible defect, a declared-contract violation or a metadata-authoring trap, so it fails the three-class test for a dev-side card."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: packages/metadata-protocol/src/protocol.batch-row-driver-text.test.ts and protocol.batch-row-http-status.test.ts build their OWN synthetic RECORD_LOCKED error and import nothing from plugin-approvals -- P4 verified, they do NOT pin this producer and they stay green. Their docblocks describe that fixture as MEASURED off plugin-approvals' lockedError, and the message half of that description is now stale; the code / statusCode / envelope half they actually assert is unchanged. Comment-accuracy drift only, in another package, outside the declared file surface -- not a defect, not a contract violation, not a metadata trap. Successor: whoever next edits the batch-row error table that those two files and protocol.ts (near its RECORD_LOCKED rows) share."
      ]
    }

    Generated by Claude Code

  4. huangyiirene commented on Sep 17, 2026

    @huangyiirene
    Collaborator

    ACCEPT — PR #18716

    domain:services seat (objectstack#6021), session_01QGMBhvUoyD8t5zY8xHQhnP, R1, written at 2026-09-17T16:58Z, head c0c490413. Readings taken by this seat against the tree and GitHub; ⛔ ⛔ none adopted from the report's self-account. ⚠️ Independence: SELF-REVIEW — mode:subagent gives dev and seat one session id by design. Implemented-by: / Reviewed-by: both session_01QGMBhvUoyD8t5zY8xHQhnP.

    Checklist conclusions

    item reading
    PR shape draft ✅ · base main ✅ · first line Fixes #18153 ✅ · ⭐ Clause-②: no on a line of its own, at line-start — the #18703 lesson applied without being asked twice.
    Scope 5 files, all inside packages/plugins/plugin-approvals/src/ + one changeset. ⛔ No breach.
    ⭐ Triage's hard constraint HELD. Four lockedError( call sites exist on both sides; the diff changes exactly one call line — the end-user-facing site. The three operator-facing sites (main :196 / :269 / :315) are untouched, their of '${object}' spelling still present and still counted 1 on main and 1 on the branch.
    RECORD_LOCKED / 409 unchanged and pinned in both directions by a dedicated test.
    No new dependency ⭐ Re-derived, ⛔ not taken on the report's word: the one added import (resolveDisplayField from @objectstack/spec/data, ADR-0079's single arbiter of the title pointer) needs nothing new — @objectstack/spec is already dependencies: workspace:*, package.json is untouched by the diff, and packages/spec file count in the diff is 0.
    CI 31 success · 3 skipped · 0 red · 0 pending. Skips = Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in) — all three in EXPECTED_SKIPS, hand-checked against the roster source because check-expected-skips.mjs cannot run here (missing yaml, no pnpm install) and ⛔ a script that cannot run is NOT MEASURED, never a clean result.
    Write budget mcp_calls 0, api_writes 2 — exactly the dispatched budget, no denied tool.

    ⭐ The implementation is better than the route this seat suggested

    My Zone 3 guessed a label lookup on the deny path and warned it might be costly or unsafe. The dev refuted that half of P2 by measurement and did something better: it added no read at all. The object label and title pointer come from the in-memory registry (engine.getSchema), and the record is ctx.previous — the pre-image the engine already binds, verified against a real ObjectQL + sqlite driver on all four update shapes.

    Two guards it added that I did not ask for and that are the difference between a fix and a disclosure bug:

    • record is used only when previousId === recordId, so a dispatch that ever carried a different row cannot title the wrong record.
    • recordTitleOf ends with if (recordId && title === recordId) return undefined ⇒ ⭐ the title can never degrade back into the id. The card's whole reason for existing is enforced structurally, not by convention.

    It also rejected two easier designs on disclosure grounds — a SYSTEM_CTX read of the record, and the payload_json snapshot — and wrote that reasoning into the module docstring rather than leaving it in a report nobody re-reads.

    Deviations — accepted, each checked

    1. Two pre-existing tests in approval-service.test.ts re-pointed. Declared rather than slipped. Both are inside the declared surface; one was the defect's own assertion, the other used the id purely as a discriminator, which moved to the console line that still carries it. ⛔ No operator-facing message touched.
    2. origin/main merged into the branch before opening the PR (AGENTS.md §10). Clean merge, no overlap with its files; packages/spec moved on main's side, so spec was rebuilt and check:generated re-run before anything was trusted.
    3. Four gates first answered exit 3 = PREREQUISITE NOT MET. ⭐ Rather than bank them as NOT MEASURED, the dev paid the prerequisite (a turbo build; one targeted git fetch of a pinned self-test fixture this shallow clone lacked) and re-ran all four to exit 0 ⇒ 62 derived / 62 run / 0 NOT-MEASURED.
    4. A container restart SIGKILLed a verification run mid-flight. The dev re-ran every verification from scratch on the merged tree rather than carrying a pre-restart green forward. ⭐ That is the correct instinct and it is the same failure this seat caused earlier in the round.

    The open question — resolved by this seat: ⛔ no follow-up card

    A formula-backed nameField is not evaluated, so those objects degrade to 「This Opportunity is locked …」 rather than naming the record. ⛔ Not filed, and the reason is the three-class test, not convenience:

    • The card's acceptance — no id, no API name, an actionable sentence — holds unconditionally, on every object, and the degradation ⛔ never reaches back for the id.
    • ⭐ The filed problem is fully fixed. This card exists because internal ids reached screenshots and screen recordings; that disclosure surface is closed everywhere. Naming the specific record is the improvement, and its limit is declared in the code, the changeset and the PR.
    • ⇒ enhancement, ⛔ not a reproducible defect, ⛔ not a declared-contract violation, ⛔ not a metadata-authoring trap. A card filed outside the three classes would be closed not planned, so filing one would be theatre.

    The dev reached the same conclusion and declined to file it itself, which was correct — that judgement is the seat's.

    Acceptance notes

    The single noted, not filed finding is agreed: the two metadata-protocol tests build their own synthetic RECORD_LOCKED fixture and import nothing from this package (P4 verified — they do not pin this producer and stayed green); their docblocks' description of that fixture is now stale in its message half only, while the code/statusCode/envelope half they actually assert is unchanged. Comment-accuracy drift in another package, outside the declared surface ⇒ ⛔ not filed.

    ⇒ ACCEPT. Path face is packages/plugins/plugin-approvals/src/** + .changeset/** — ⛔ no governed surface. Landing by the ordinary route: ready → auto-merge → merge queue.


    Generated by Claude Code

  5. removed their assignment
    on Sep 17, 2026
  6. huangyiirene commented on Sep 17, 2026

    @huangyiirene
    Collaborator

    LANDED — PR #18716 merged.

    domain:services seat (objectstack#6021), session_01QGMBhvUoyD8t5zY8xHQhnP, R1, written at 2026-09-17T17:25Z. Judged on origin/main after the merge, ⛔ never on the PR-closed event. Card closed completed by the PR's Fixes keyword; this seat stripped pm:dispatched and cleared the assignee in one write, read back clean. priority:p2 and domain:services stay — ⛔ ownership is not state.

    Landing probe — delivery + positive control + negative control

    commit window (400) on origin/main:
      (#18716) = 1   ← the delivery
      (#18713) = 1   ← positive control (known merged)
      (#99999) = 0   ← negative control
    
    content on origin/main, packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:
      function recordLockRefusal                    = 1
      function recordTitleOf                        = 1
      `lockedError(` occurrences                    = 5   ← 1 definition + the SAME 4 call sites as before
      record-lock-user-facing-message.test.ts       = present
    

    ⭐ The lockedError( count is the check that matters here, and it reads 5 on main exactly as it did before: the definition plus four call sites. Triage's hard constraint was that only the end-user-facing site changes and the three operator-facing sites stay untouched — that count is how a later reader confirms none was swept up.

    What shipped

    The refusal an end user sees when they touch a record locked by an in-progress approval now names the record by its object label plus its ADR-0079 title — degrading to This Opportunity is locked …, then This record is locked …, and ⛔ never back to the id, which is enforced structurally (if (title === recordId) return undefined). The internal record id, the object API name and the pending request id move to the console via logger.info.

    ⭐ No read was added. The label and title pointer come from the in-memory registry, and the record is ctx.previous — the pre-image the engine already binds — used only when previousId === recordId, so a dispatch carrying a different row cannot mistitle the record. A SYSTEM_CTX read and the payload_json snapshot were both considered and rejected on disclosure grounds, with that reasoning written into the module docstring rather than left in a report.

    RECORD_LOCKED and its 409 are unchanged and pinned in both directions.

    Declared limit — ⛔ recorded, deliberately not filed

    A formula-backed nameField is not evaluated, so on those objects the message degrades to the label-only form rather than naming the record. ⭐ The filed problem is fully fixed — internal ids no longer reach the toast on any object, which is why this card existed (screenshots, screen recordings, support tickets). Naming the specific record is the improvement, and its limit is declared in the code, the changeset and the PR.

    ⛔ No follow-up card, and the reason is the three-class test rather than convenience: it is an enhancement, ⛔ not a reproducible defect, ⛔ not a declared-contract violation, ⛔ not a metadata-authoring trap. A card outside those three would be closed not planned, so filing one would be theatre. The delivering dev reached the same conclusion and correctly left the judgement to the seat.

    Full verdict and per-item readings: #issuecomment-5718179827.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions