You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a00cf99
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,plugin-approvals): enable.approvalsVisibleToReaders, the per-object opt-in for the read-only record-reader approval tier (#22660)
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.recordReaderVisibleObjects` constructor
option. A config-driven app cannot set it, because its host builds the
plugin with no options.
- **Spec:** `ObjectCapabilities` (the object's `enable` block) gains
`approvalsVisibleToReaders: boolean`, default `false`. Its `.describe()`
states what `true` grants 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.
- **Plugin:** `ApprovalService.addRecordReaderVisibleIds` asks a new
private `recordReaderTierOn(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)`) declares
`enable.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 `approvalsVisibleToReaders`
It sits in the `enable` block beside `trackHistory`, `apiEnabled`,
`files`, `feeds`, `activities` and `clone`. Like `apiEnabled`, 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 bare `approvals` was rejected.
Under `enable`, 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 in `init()`. Objects from
installed packages (`kernel:ready`), from a later `start()`, 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.
- **Default OFF cost:** at most one in-memory registry lookup per read
that names a record, and none for an untargeted read (the inbox). The
business record is never probed for an object that declares nothing: the
size-0 early return could no longer stand alone, so the check moved
behind the object/record test.
- **Fails closed:** an engine without `getSchema` (the optional member
of `ApprovalEngine`), an unregistered name, a throwing lookup, or any
value but literal `true` reads as not declared.
- **One visibility definition, both doors:** unchanged.
`requestVisibilitySourceOf` hands the same `visibleRequestIds` to
`bindRequestReadGate` and `bindRequestChildReadGates`. The flag widens
`sys_approval_request`, `sys_approval_action` and
`sys_approval_approver` on both doors together. The new pins read all
three on both doors.
### Declaration debts
- Liveness row `packages/spec/liveness/object.json` →
`enable.children.approvalsVisibleToReaders`: `live`, evidence anchored
on `approval-service.ts#recordReaderTierOn`.
- Studio: the object form's Capabilities section gets the toggle with a
help text (`object.form.ts`). The metadata-forms bundles are regenerated
by `node scripts/check-i18n-bundles.mjs --write`. The zh-CN, ja-JP and
es-ES leaves are hand-written, and
`object-collapsed-sections-echo-decisions.test.ts` carries a decided row
for each of the two new leaves, with its counts moved (capabilities 9 →
11 leaves).
- Generated: `authorable-surface/data.json` and
`authorable-defaults/data.json` (from the spec build),
`content/docs/references/data/object.mdx` (`gen:docs`) and
`liveness/state-counts/object.md` (`gen:liveness-counts`), as
`check:generated` named them. `authorable-surface.base.json` is
untouched.
- Changesets: `@objectstack/spec` minor and
`@objectstack/plugin-approvals` minor, each `Clause-②: yes (widening)`;
and `@objectstack/platform-objects` patch, `Clause-②: no`, for the form
row's translated leaves (contract review round 1 `6096311080`).
## Tests
All runs below went through `scripts/pm/os-verify-lock.sh`, and each
quotes the run's own summary line.
- **`@objectstack/plugin-approvals`**, at `fe81af4642`: `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 --listFiles` includes
`record-reader-opt-in.integration.test.ts` (1 hit).
- **`@objectstack/spec`**, at `fe81af4642`: `vitest run --project local`
→ `Test Files 642 passed (642)`, `Tests 19150 passed | 1 todo`.
`typecheck` → exit 0.
- **`@objectstack/platform-objects`**, at `e0c562f1a9`: `vitest run` →
`Test Files 69 passed (69)`, `Tests 1082 passed (1082)`. `typecheck` →
exit 0. At `fe81af4642`, one pin went red: the catalog-wide
translated-label control in
`object-lifecycle-panel-echo-decisions.test.ts` read 668 against 667,
because this PR adds one authored label. `e0c562f1a9` moves that count.
`git diff fe81af4e0c562f` touches only that file, so the spec and
plugin readings above stand for the final head.
- **New pins**, `record-reader-opt-in.integration.test.ts` (8 cases). It
uses a real ObjectQL engine over better-sqlite3, the real
`ApprovalsServicePlugin.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.
- An object declaring the flag, with the host option empty: a reader of
the record sees everything, read-only. `viewer` is `{can_act: false,
…}`; decide, reassign, comment and recall refuse with `FORBIDDEN:`; the
data-door row carries no `viewer`.
- A reader on another record, and a caller with no read: nothing, and
404 `RECORD_NOT_FOUND` on the data door.
- A read that names no record: the inbox is not widened.
- Controls: an undeclared object is unchanged, participants are
unchanged, and the host option works alone.
- Late registration: an object registered after `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.
- **Spec pins** (`object.test.ts`): the default is `false`; the key is
accepted on `ObjectSchema` and read back `true`; a string value is
refused with `invalid_type`.
- **Ablations**, through `scripts/ablation-replace.mjs` (wrap mode;
anchor must hit; restore proven by blob equality with HEAD and an empty
`git diff HEAD`). The subject resolves from `src` by relative import, so
no `dist/` leg applies. The mutated file's HEAD blob is `8d7983b7e922`,
the same blob at the final head.
- (1) The declared-key read replaced by `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.
- (2) The size-0 early return put back before the declared read: `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 read `blob == HEAD
(8d7983b) and git diff HEAD is empty`.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) at `e0c562f1a9` derived 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. `--ran`
reconciles: `115 derived, 114 run, 0 NOT-MEASURED, 1 UNRUN` (the unrun
one is `check:dual-build-cjs-loads`).
- Exit 1: `pnpm check:platform-checklist`, red on `origin/main`
`86da194919` too. Its 7 problems are symbol anchors in
`docs/qa/platform-checklist/areas/access-security.json` and
`attachments-storage.json` naming symbols absent from
`metadata-protocol/src/protocol.ts` and
`service-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.
- Verdict lines: `check:generated` → `✓ All 15 generated artifacts are
up to date`. `check:liveness` → `object 54 classified (live 53, planned
1)`, every `path#symbol` anchor 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
- **Kernel probe (throwaway, not committed).** ObjectKernel +
ObjectQLPlugin, with producers registering through the manifest service
in `init()`, in a `start()` composed after approvals, and on
`kernel:ready`. Each object declares the flag. The reading was taken
inside approvals' `start()`, after boot, and after the `kernel:ready`
producer 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 no `getSchema` answers `false`. What `getSchema`
returns through that door is the authored literal: the declared object's
`enable` reads `{"approvalsVisibleToReaders":true}` with no defaults
filled in, and an object with no block reads `undefined`. So an absent
block or flag reads as the spec default, `false`.
- The option's doc linked `ApprovalService.recordReaderVisibleIds`, a
member that does not exist. It now links `addRecordReaderVisibleIds`, in
the hunk this PR edits anyway.
- The en label is the extractor's humanize of the key, "Approvals
Visible To Readers" (the form declares no label), like its siblings in
that block.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Studio's object form now offers the `approvalsVisibleToReaders` row in its capabilities panel, with its label and help text in the metadata-form translation catalogs for `en`, `zh-CN`, `ja-JP` and `es-ES`.
feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration
6
+
7
+
Clause-②: yes (widening)
8
+
9
+
-**Before this,** the read-only record-reader tier could be switched on only through the `recordReaderVisibleObjects` constructor option. A config-driven app, whose host builds the plugin with no options, had no way to reach it.
10
+
-**Now** the tier is on for an object when either source turns it on: the host's `recordReaderVisibleObjects`, or the object's own `enable.approvalsVisibleToReaders: true`. With neither, visibility is exactly as before.
11
+
-**Read where it is used.** The service reads the declaration from the object's live registered definition on each read that names a record, not once at start. An object registered after boot (an installed package, a Studio edit, a dev reload) takes effect at once. Removing the flag turns the tier off at once, with no restart.
12
+
-**Fails closed.** An engine that cannot answer an object definition, an unknown object, or any value other than `true` leaves the tier off. Default OFF costs one in-memory registry lookup on a read that names a record. The business record is never probed for an object that declares nothing.
13
+
-**Unchanged.** The rule itself: who counts as a reader, the read-only boundary, the request tables it covers on both doors, and the constructor option.
feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers
6
+
7
+
Clause-②: yes (widening)
8
+
9
+
-**The key.**`ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged.
10
+
-**What `true` grants.** A caller who can read a record of the object sees that record's approval requests and their full action history, read-only. "Can read" is the object's own CRUD, sharing and RLS, asked as the caller. No new permission is granted. This holds on the approvals API and on the generic data API alike (`sys_approval_request`, `sys_approval_action` and `sys_approval_approver`), on a read that names the record. A list that names no record, such as the inbox, is not widened. No approval action is offered: approve, reject, reassign, recall and comment keep authorizing as before.
11
+
-**What becomes visible.** The request row, including its snapshot of the record at submission, and each action's actor, decision, time, comment text and attachments. Turn it on only for objects whose approval commentary the record's readers are meant to see.
12
+
-**Studio.** The object form's Capabilities section lists the new toggle.
13
+
-**Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it.
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -430,6 +430,7 @@ const result = ApiMethod.parse(data);
430
430
|**feeds**|`boolean`| optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
431
431
|**activities**|`boolean`| optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
432
432
|**clone**|`boolean`| optional (default: `true`) | Allow record deep cloning |
433
+
|**approvalsVisibleToReaders**|`boolean`| optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it |
433
434
434
435
### Nested Shape: `Object.publicSharing`
435
436
@@ -533,6 +534,7 @@ const result = ApiMethod.parse(data);
533
534
|**feeds**|`boolean`| optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
534
535
|**activities**|`boolean`| optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
535
536
|**clone**|`boolean`| optional (default: `true`) | Allow record deep cloning |
537
+
|**approvalsVisibleToReaders**|`boolean`| optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it |
helpText: "Readers of a record see its approval requests and history, read-only, with no approval action"
299
+
},
296
300
validations: {
297
301
label: "Validations",
298
302
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
helpText: "Quien puede leer un registro ve sus solicitudes de aprobación y su historial, en solo lectura y sin ninguna acción de aprobación"
299
+
},
296
300
validations: {
297
301
label: "Validaciones",
298
302
helpText: "Reglas de validación a nivel de objeto — un array de objetos de regla, p. ej. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. Las tablas de transición de máquinas de estado también se declaran aquí (ADR-0020)"
'⛔ NO AUTHORED TWIN FOR "clone" OR "cloning" IN EITHER CATALOG — stated, not borrowed. The nearest thing either catalog holds is object.fields["fields.unique"].helpText ("Disallow duplicate values") 不允许重复值 / 重複値を許可しない / No permite valores duplicados, and that 重复 / 重複 / duplicado is the "repeated value" sense, ⛔ not the "make a copy of this record" sense the schema means ("Allow record deep cloning"). ⇒ composed: 克隆 / クローン / Clonación. ⚠️ es takes the NOUN (Clonación) rather than the infinitive, following its own siblings on this panel, which are nominal or adjectival throughout (Archivos, Actividades, Buscable, Seguimiento de historial) — and unlike enable.label above, which copies a twin that is itself an action label.',
315
315
},
316
+
{
317
+
section: 'capabilities',
318
+
path: 'enable.approvalsVisibleToReaders',
319
+
prop: 'label',
320
+
en: 'Approvals Visible To Readers',
321
+
verdict: ALL_TRANSLATE,
322
+
reason:
323
+
'ARRIVED WITH THE FLAG ITSELF (the per-object opt-in for the read-only record-reader approval tier), so all three leaves were authored by the card that added the row, never left as fills. The English is the extractor humanize of the key (objectForm declares no label, asserted by the third leg), decided against the CONCEPT: who sees what. "Approvals" takes the approvals plugin\'s own authored head noun, sys_approval_request.label 审批请求 / 承認リクエスト / Solicitud de aprobación ⇒ 审批 / 承認 / Aprobaciones. "Readers" takes the reader twin on the view panel, the visibility section description ("who can see it") 谁可以查看 / 閲覧可能者 / quién puede verla ⇒ 读者 / 閲覧者 / lectores. ⇒ 审批对读者可见 / 閲覧者に承認を表示 / Aprobaciones visibles para lectores. ⛔ NOT 只读 / 読み取り専用 / Solo lectura in the label: that is the read-only BOUNDARY, which the helpText below states, and this catalog spends those words on the readonly field row.',
324
+
},
325
+
{
326
+
section: 'capabilities',
327
+
path: 'enable.approvalsVisibleToReaders',
328
+
prop: 'helpText',
329
+
en: 'Readers of a record see its approval requests and history, read-only, with no approval action',
330
+
verdict: ALL_TRANSLATE,
331
+
reason:
332
+
'EVERY NOUN HAS AN AUTHORED TWIN and the row names each, because this sentence states what a security-relevant switch grants and a loose word would widen it. "approval requests": sys_approval_request.pluralLabel 审批请求 / 承認リクエスト / Solicitudes de aprobación. "approval action": sys_approval_action.pluralLabel 审批动作 / 承認アクション / Acciones de aprobación, so the leaf names the same rows the tier refuses to act on. "read-only": fields.readonly.label 只读 / 読み取り専用 / Solo lectura. "history": the 历史 / 履歴 / historial of enable.trackHistory\'s twin. "can read a record": 读取 / 閲覧 / leer, the record-read the tier anchors on. ⇒ 能读取记录的用户可只读查看该记录的审批请求与历史,不提供任何审批动作 / レコードを閲覧できるユーザーが、その承認リクエストと履歴を読み取り専用で参照できます。承認アクションは提供されません / Quien puede leer un registro ve sus solicitudes de aprobación y su historial, en solo lectura y sin ninguna acción de aprobación. ⚠️ zh and ja name the grantee as the user who can read the record rather than "readers", so the sentence cannot be read as granting anything to a reader of the approval tables themselves.',
0 commit comments