Repository navigation
feat(spec,plugin-approvals): enable.approvalsVisibleToReaders, the per-object opt-in for the read-only record-reader approval tier - #22660
Conversation
…r-object opt-in for the record-reader approval tier An object's own metadata can now switch on the ruled read-only record-reader visibility tier. The approvals service reads the flag from the object's live registered definition on every read, beside the host's recordReaderVisibleObjects constructor set; either source suffices, and the default stays OFF. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
… both doors; changesets Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
…ey renders into authorable-surface and authorable-defaults (written by the spec build), the object reference page (gen:docs) and the object liveness count shard (gen:liveness-counts), as check:generated named them. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
…oReaders toggle; decide its two leaves The metadata-forms bundles regenerated through check-i18n-bundles --write, with the zh-CN, ja-JP and es-ES leaf values written by hand, and the collapsed-sections echo ledger carrying a decided row for each new leaf. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
…cord-reader-opt-in
…ds 668 with the opt-in toggle's label Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 fba0eb17ee69bb834ed4f639037cf414cc13680b && git checkout fba0eb17ee69bb834ed4f639037cf414cc13680b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee3ae0360dc5beff655e4feb8f9624c731e306fd b275dc8619fc4fbf3d9a40899d7d2d57f262c30d && git checkout -B drift-repro ee3ae0360dc5beff655e4feb8f9624c731e306fd && git merge --no-ff b275dc8619fc4fbf3d9a40899d7d2d57f262c30d
node scripts/docs-audit/affected-docs.mjs --json ee3ae0360dc5beff655e4feb8f9624c731e306fd
|
Contract reviewServed-tier: Net diff read against the merge base with ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong.
② Semver level
③ Boundary flagsDev deviations (report
Out-of-scope finding (escalated, not answered here): Open questions: the report lists none and this review found none. Check-runs on the head, read at 2026-10-10T09:53Z: 33 check-runs; 23 success, 2 skipped (Console Pin Gate, Packed-tarball smoke opt-in), 0 failure; 8 still in progress: Lint & Repo Gates, Test Core 1 to 6 of 6, Type Check · workspace. Judged on the concluded ones, every gate verdict is green, among them Check Changeset, Spec property liveness, Build Core, Build Docs, Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Temporal Conformance, Governed Surface Queue Guard, Dogfood Regression Gate 1 to 3 of 3, Dogfood Verify CLI, and the three claim guards. Verdict basis: ① holds in full and ③ carries no breach; the verdict is FAIL on ② alone, for the missing Implemented-by: VERDICT: FAIL Generated by Claude Code |
…aders form row's translated leaves Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Round 2, scoped. Round 1 ( What moved, verified from the refs, not the report: ① Derived judgmentsThe new file, line by line, against the hunks it describes and the precedents round 1 cited:
② Semver levelEvery package whose
③ Boundary flagsRound 1's FAIL item: cured by the one file above. The seat order asked for that file and nothing else, and nothing else moved. Dev deviations (report
Held from round 1, unchanged by this file: the two Open questions: the report lists none and this review found none. Check-runs on Verdict basis: the one file round 1 owed is present, with the level, the sentence and the clause that round 1's own reasoning and the precedents call for; nothing else moved; ① and ③ stay held with no breach. The check-runs still in progress are the landing pre-check's to settle, not this record's: a FAIL here would need a concluded red or a defect in the diff, and there is neither. Implemented-by: VERDICT: PASS Generated by Claude Code |
…cord-reader-opt-in
…tree The os-regen driver kept the branch's side of the generated shard in the merge commit and dropped main's two FileRefusedValue entries; gen:schema over the merged tree carries both main's entries and this branch's approvalsVisibleToReaders. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Round 3, scoped to the ① Derived judgments1. The union is exact.
2. The interaction: confirmed from the code, in both directions. What PR #22641 ( Does the action page read the record-reader tier? No. The chain at this head: Does the tier reach the action page the other way? No. 3. Held from round 2, on hunks now proven identical: the spec key and its ② Semver levelUnchanged by the hop, and the hop could not change it: the three changesets are byte-identical to round 2's, ③ Boundary flagsGoverned surfaces: the branch now contains Dev deviations (report
Still open from round 2, not a breach: the PR body's "Changesets" bullet names two entries where the head carries three. The seat's order Open questions: the report lists none and this review found none. Check-runs on Verdict basis: the hop is a clean three-way union on the two files both sides edited, proven by blob identity; the PR's delta against Posted 2026-10-10T10:49Z. Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing pre-checks at
|
Fixes #22560
Clause-②: yes (widening)
What this does
An object's own metadata can now turn on the ruled read-only record-reader approval tier (#8652). Before this, the only switch was the
ApprovalsPluginOptions.recordReaderVisibleObjectsconstructor option. A config-driven app cannot set it, because its host builds the plugin with no options.ObjectCapabilities(the object'senableblock) gainsapprovalsVisibleToReaders: boolean, defaultfalse. Its.describe()states whattruegrants and to whom: a caller who can read a record of the object sees that record's approval requests and full action history, read-only. This holds on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered.ApprovalService.addRecordReaderVisibleIdsasks a new privaterecordReaderTierOn(object)per call. It answers true if the host's constructor set holds the object, or if the object's live registered definition (this.engine.getSchema(object)) declaresenable.approvalsVisibleToReaders === true. This is the seat's decision B on the round-1 fork (6094828840), AGENTS.md "Startup registry reads" cure 1.approvals-plugin.ts: doc text only. The constructor option stays for hosts that build the plugin themselves.The ruling is unchanged (#8652,
5299823744, maintainer 「同意」): "A user with read access to the target business record may view that record's approval requests and full action history, read-only … Enabled by a per-object or plugin-level switch, default OFF … the downstream project opts in."Why the name
approvalsVisibleToReadersIt sits in the
enableblock besidetrackHistory,apiEnabled,files,feeds,activitiesandclone. LikeapiEnabled, it is a flag phrase that says what turning it on does. It names the subject (approvals), the grant (visible, so read-only and not actionable) and the grantee (readers of the record). A bareapprovalswas rejected. Underenable, it reads as "turn approvals on for this object", and an author or an AI would set it to get approval processes. Approval processes need no such flag, so that reading would silently widen visibility instead.Where the declaration is read, and why there
The service read the option once, in its constructor, which the plugin calls in
start(). A round-1 kernel probe showed that at that moment the registry holds only objects registered ininit(). Objects from installed packages (kernel:ready), from a laterstart(), from Studio edits and from dev reloads arrive afterwards. A set collected at start would leave those declarations inert. It would also keep a removed declaration in force until restart, an exposure the author believes is closed. So the flag is read where it is used, from the registry as it is at the read.getSchema(the optional member ofApprovalEngine), an unregistered name, a throwing lookup, or any value but literaltruereads as not declared.requestVisibilitySourceOfhands the samevisibleRequestIdstobindRequestReadGateandbindRequestChildReadGates. The flag widenssys_approval_request,sys_approval_actionandsys_approval_approveron both doors together. The new pins read all three on both doors.Declaration debts
packages/spec/liveness/object.json→enable.children.approvalsVisibleToReaders:live, evidence anchored onapproval-service.ts#recordReaderTierOn.object.form.ts). The metadata-forms bundles are regenerated bynode scripts/check-i18n-bundles.mjs --write. The zh-CN, ja-JP and es-ES leaves are hand-written, andobject-collapsed-sections-echo-decisions.test.tscarries a decided row for each of the two new leaves, with its counts moved (capabilities 9 → 11 leaves).authorable-surface/data.jsonandauthorable-defaults/data.json(from the spec build),content/docs/references/data/object.mdx(gen:docs) andliveness/state-counts/object.md(gen:liveness-counts), ascheck:generatednamed them.authorable-surface.base.jsonis untouched.@objectstack/specminor and@objectstack/plugin-approvalsminor, eachClause-②: yes (widening); and@objectstack/platform-objectspatch,Clause-②: no, for the form row's translated leaves (contract review round 16096311080).Tests
All runs below went through
scripts/pm/os-verify-lock.sh, and each quotes the run's own summary line.@objectstack/plugin-approvals, atfe81af4642:vitest run→Test Files 70 passed (70),Tests 1011 passed (1011).typecheck(tsc + scripts +check:test-typecheck) →VERDICT command-exit 0.tsc -p tsconfig.test.json --listFilesincludesrecord-reader-opt-in.integration.test.ts(1 hit).@objectstack/spec, atfe81af4642:vitest run --project local→Test Files 642 passed (642),Tests 19150 passed | 1 todo.typecheck→ exit 0.@objectstack/platform-objects, ate0c562f1a9:vitest run→Test Files 69 passed (69),Tests 1082 passed (1082).typecheck→ exit 0. Atfe81af4642, one pin went red: the catalog-wide translated-label control inobject-lifecycle-panel-echo-decisions.test.tsread 668 against 667, because this PR adds one authored label.e0c562f1a9moves that count.git diff fe81af4642 e0c562f1a9touches only that file, so the spec and plugin readings above stand for the final head.record-reader-opt-in.integration.test.ts(8 cases). It uses a real ObjectQL engine over better-sqlite3, the realApprovalsServicePlugin.start()and the real data-door normalizer. Each pin compares one reading: the approvals door (list, by id, history) plus the data door's list and by-id reads of all three request tables.vieweris{can_act: false, …}; decide, reassign, comment and recall refuse withFORBIDDEN:; the data-door row carries noviewer.RECORD_NOT_FOUNDon the data door.start()with the flag widens; re-registered without it, it stops, with no restart. The registry's own answer flipping is asserted as that pin's control.object.test.ts): the default isfalse; the key is accepted onObjectSchemaand read backtrue; a string value is refused withinvalid_type.scripts/ablation-replace.mjs(wrap mode; anchor must hit; restore proven by blob equality with HEAD and an emptygit diff HEAD). The subject resolves fromsrcby relative import, so nodist/leg applies. The mutated file's HEAD blob is8d7983b7e922, the same blob at the final head.return false:Tests 3 failed | 5 passed (8). Red: the declared reader pin, the read-only pin and the late-registration pin. Green, as expected: the cannot-read, inbox and control pins.Tests 3 failed | 5 passed (8), the same three red. The first attempt at (2) was refused by the tool before any test ran: the replacement contained its own anchor, so the anchor count could not drop. It was re-anchored on the comment line above. Both restores readblob == HEAD (8d7983b7e922) and git diff HEAD is empty.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) ate0c562f1a9derived 115 commands. All were run, plus the 48 artifact-roster commands that need no PR context. Result: 161 exit 0, 1 exit 1, 1 NOT MEASURED.--ranreconciles:115 derived, 114 run, 0 NOT-MEASURED, 1 UNRUN(the unrun one ischeck:dual-build-cjs-loads).pnpm check:platform-checklist, red onorigin/main86da194919too. Its 7 problems are symbol anchors indocs/qa/platform-checklist/areas/access-security.jsonandattachments-storage.jsonnaming symbols absent frommetadata-protocol/src/protocol.tsandservice-storage/src/attachment-access-hooks.ts. This diff touches none of those files.check:dual-build-cjs-loads: NOT MEASURED, reason: it needs a whole-workspace build, which this dispatch rules out.check:generated→✓ All 15 generated artifacts are up to date.check:liveness→object 54 classified (live 53, planned 1), everypath#symbolanchor resolves.check:i18n→OK (9 package(s) — all bundles in sync…).check:i18n-stale-fill→0 stale-fill.check:api-surface→unchanged ✓.check:nul-bytes→OK.check:type-check-debt→none above its recorded number.Acceptance notes
init(), in astart()composed after approvals, and onkernel:ready. Each object declares the flag. The reading was taken inside approvals'start(), after boot, and after thekernel:readyproducer re-registered its object without the flag:seenAtStart {init: true, start: false, ready: false},afterBoot {init: true, start: true, ready: true, plain: false, never_registered: false},afterRemoval false. An engine with nogetSchemaanswersfalse. WhatgetSchemareturns through that door is the authored literal: the declared object'senablereads{"approvalsVisibleToReaders":true}with no defaults filled in, and an object with no block readsundefined. So an absent block or flag reads as the spec default,false.ApprovalService.recordReaderVisibleIds, a member that does not exist. It now linksaddRecordReaderVisibleIds, in the hunk this PR edits anyway.Generated by Claude Code