Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/platform-objects': patch
---

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`.

Clause-②: no
13 changes: 13 additions & 0 deletions .changeset/22560-plugin-approvals-declared-record-reader-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/plugin-approvals': minor
---

feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **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.
- **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.
13 changes: 13 additions & 0 deletions .changeset/22560-spec-approvals-visible-to-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
---

feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers

Clause-②: yes (widening)

- **The key.** `ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged.
- **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.
- **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.
- **Studio.** The object form's Capabilities section lists the new toggle.
- **Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it.
2 changes: 2 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ const result = ApiMethod.parse(data);
| **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 |
| **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
| **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |
| **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 |

### Nested Shape: `Object.publicSharing`

Expand Down Expand Up @@ -532,6 +533,7 @@ const result = ApiMethod.parse(data);
| **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 |
| **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
| **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |
| **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 |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
"enable.clone": {
label: "Clone"
},
"enable.approvalsVisibleToReaders": {
label: "Approvals Visible To Readers",
helpText: "Readers of a record see its approval requests and history, read-only, with no approval action"
},
validations: {
label: "Validations",
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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "Clonación"
},
"enable.approvalsVisibleToReaders": {
label: "Aprobaciones visibles para lectores",
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"
},
validations: {
label: "Validaciones",
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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "クローン"
},
"enable.approvalsVisibleToReaders": {
label: "閲覧者に承認を表示",
helpText: "レコードを閲覧できるユーザーが、その承認リクエストと履歴を読み取り専用で参照できます。承認アクションは提供されません"
},
validations: {
label: "検証ルール",
helpText: "オブジェクトレベルの検証ルール — ルールオブジェクトの配列。例: [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。ステートマシンの遷移テーブルもここで宣言します(ADR-0020)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,24 @@ const DECISIONS: readonly Decision[] = [
reason:
'⛔ 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.',
},
{
section: 'capabilities',
path: 'enable.approvalsVisibleToReaders',
prop: 'label',
en: 'Approvals Visible To Readers',
verdict: ALL_TRANSLATE,
reason:
'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.',
},
{
section: 'capabilities',
path: 'enable.approvalsVisibleToReaders',
prop: 'helpText',
en: 'Readers of a record see its approval requests and history, read-only, with no approval action',
verdict: ALL_TRANSLATE,
reason:
'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.',
},
{
section: 'advanced',
path: 'validations',
Expand Down Expand Up @@ -487,9 +505,10 @@ const MINIMAL_OBJECT = { name: 'acct', label: 'Acct', fields: { id: { type: 'tex

describe('#19403 round 8 — the ledger itself (controls before verdicts)', () => {
it('decides every string leaf of the two keys this round takes, and nothing else', () => {
// Lit — the ledger is the size it claims: 9 form rows, 11 leaves, three
// locales, 33 decisions.
expect(DECISIONS.length).toBe(11);
// Lit — the ledger is the size it claims: 10 form rows, 13 leaves, three
// locales, 39 decisions (the opt-in flag's row brought one form row, two
// leaves and six decisions).
expect(DECISIONS.length).toBe(13);
expect(new Set(DECISIONS.map((d) => d.path))).toEqual(
new Set([
'enable',
Expand All @@ -500,12 +519,13 @@ describe('#19403 round 8 — the ledger itself (controls before verdicts)', () =
'enable.feeds',
'enable.activities',
'enable.clone',
'enable.approvalsVisibleToReaders',
'validations',
]),
);
expect(DECISIONS.filter((d) => d.prop === 'label').length).toBe(9);
expect(DECISIONS.filter((d) => d.prop === 'helpText').length).toBe(2);
expect(DECISIONS.flatMap((d) => Object.keys(d.verdict)).length).toBe(33);
expect(DECISIONS.filter((d) => d.prop === 'label').length).toBe(10);
expect(DECISIONS.filter((d) => d.prop === 'helpText').length).toBe(3);
expect(DECISIONS.flatMap((d) => Object.keys(d.verdict)).length).toBe(39);
for (const d of DECISIONS) {
expect(Object.keys(d.verdict).sort(), `${idOf(d)} names every translated locale`).toEqual([
'es-ES',
Expand Down Expand Up @@ -685,7 +705,7 @@ describe('#19403 round 8 — ADR-0020 and the validations schema, asserted AT TH
// The class-(c) question, answered at the schema rather than assumed: could
// translating these labels produce metadata the runtime rejects? No — the
// labels name keys whose values are booleans, and the block is strict.
for (const key of ['trackHistory', 'searchable', 'apiEnabled', 'files', 'feeds', 'activities', 'clone']) {
for (const key of ['trackHistory', 'searchable', 'apiEnabled', 'files', 'feeds', 'activities', 'clone', 'approvalsVisibleToReaders']) {
expect(ObjectCapabilities.safeParse({ [key]: true }).success, `${key} is a declared capability`).toBe(true);
expect(
ObjectCapabilities.safeParse({ [key]: 'Clone' }).success,
Expand Down Expand Up @@ -918,9 +938,13 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape',
// a label and a help text each, thirty-six leaves, all hundred and eight
// translated leaves authored by the same flight. `advanced` reads 60 → 96;
// `capabilities` is untouched.
expect(PANEL_LEAVES.length).toBe(105);
// 107 since the record-reader approval opt-in gave `capabilities` its
// `enable.approvalsVisibleToReaders` toggle — a label and a help text, two
// leaves, all six translated leaves authored by the same card, decided in
// the two rows above. `capabilities` reads 9 → 11; `advanced` is untouched.
expect(PANEL_LEAVES.length).toBe(107);
expect(PANEL_LEAVES.every((l) => l.prop === 'label' || l.prop === 'helpText')).toBe(true);
expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(9);
expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(11);
expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(96);
});

Expand Down Expand Up @@ -1063,14 +1087,14 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape',
expect(flagged.length).toBe(PANEL_LEAVES.length);
});

it('⭐ the capability block is now DONE — zero of its 9 leaves echoes in any locale', () => {
it('⭐ the capability block is now DONE — zero of its 11 leaves echoes in any locale', () => {
const capability = PANEL_LEAVES.filter((l) => l.section === 'capabilities');
const echoing = capability.filter((l) =>
TRANSLATED_LOCALES.some(([, forms]) => catalogLeaf(forms, l.path, l.prop) === l.en),
);
expect(echoing.map((l) => `${l.path}.${l.prop}`)).toEqual([]);
// Lit — and it really walked the section, which a zero alone would not show.
expect(capability.length).toBe(9);
expect(capability.length).toBe(11);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// 667 since the field form offers the deadline pair on date and datetime
// fields, `dueLike` and `settledWhen` — two new row labels, authored in
// all three locales.
expect(translated.length, `${locale} positive control`).toBe(667);
// 668 since the object form offers `enable.approvalsVisibleToReaders`,
// the per-object opt-in for the read-only record-reader approval tier —
// one new row label, authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(668);
}
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
// all-three predicate returns 0 while the per-locale one returns 1, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "克隆"
},
"enable.approvalsVisibleToReaders": {
label: "审批对读者可见",
helpText: "能读取记录的用户可只读查看该记录的审批请求与历史,不提供任何审批动作"
},
validations: {
label: "校验规则",
helpText: "对象级校验规则——由规则对象组成的数组,例如 [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。状态机转移表也在此声明(ADR-0020)"
Expand Down
Loading
Loading