Skip to content

Commit 0790696

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21558-container-own-expansion-name
2 parents f033e0a + 54521f0 commit 0790696

11 files changed

Lines changed: 432 additions & 59 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Liveness ledger: a permission set's row-level security policy `label` and `description` (`rowLevelSecurity[].label` / `.description`) are now `live`, not `dead`. Studio's permission editor shows both on every policy. Ledger data and its generated count shard only.
6+
7+
Clause-②: no
8+
9+
- **What shows them.** These are display keys, so under the ledger's "Designer previews count as consumers" ruling, being shown to a human is the whole of their claimed effect. The Row-Level Security section of the permission editor (`PermissionAdvancedFacets` in objectui) now heads each policy card with the policy's `label` and, beneath it, its `description`, exactly as written. Both rows cite that reader at the `.objectui-sha` pin `89cad75d557`. The registered permission preview also draws both, but no route mounts it for `permission`, so it is not cited.
10+
- **Where the values come from.** Each row names its producer: the `permission` edit page registration and the Studio edit route that mounts it, the editor's `GET /api/v1/meta/permission/:name/layers` read, and this repo's shared layered answer (`createMetaLayeredAnswer`), which serves a permission set whole. The showcase's contributor permission set authors both keys on all three of its policies.
11+
- **Author-facing effect.** `os lint` / `os validate` no longer warn `liveness-dead-property` on a policy that sets `label` or `description`. A warning is not a refusal, so the accept set is unchanged.
12+
- **Still kept.** The re-grade reverses no ADR-0033 decision. Both rows stay docs-shaped annotation, deliberately kept and not `authorWarn`'d.
13+
- The regenerated liveness count is the `liveness/state-counts/permission.md` shard: `permission` has 38 live and 4 dead (was 36 and 6). The `view` container's own `label` stays `dead`.
14+
- ⛔ No schema, parse, `.describe()`, export or accept-set change.

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 104 additions & 6 deletions
Large diffs are not rendered by default.

‎docs/qa/platform-checklist/coverage.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"api-backend.packaged-action-disabled-dispatch",
1010
"api-backend.action-activation-door-contract",
1111
"automation.setup-packaged-automation-board",
12-
"platform-core.activation-ledger-registration-home"
12+
"platform-core.activation-ledger-registration-home",
13+
"records-forms.script-action-hook-refusal-toast"
1314
]
1415
},
1516
"agent": {
@@ -154,7 +155,8 @@
154155
"items": [
155156
"records-forms.object-hook-lifecycle",
156157
"cli.hook-body-extraction-gates",
157-
"access-security.record-view-read-audit"
158+
"access-security.record-view-read-audit",
159+
"records-forms.script-action-hook-refusal-toast"
158160
]
159161
},
160162
"job": {

‎examples/app-showcase/src/data/hooks/index.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,50 @@ export const StampInquiryDefaultsHook = {
119119
description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).',
120120
};
121121

122+
/**
123+
* The sentence {@link GuardTaskReopenHook} refuses with. Exported so the
124+
* fixture's test quotes the same string the hook throws.
125+
*/
126+
export const TASK_REOPEN_REFUSAL =
127+
'A finished task cannot be reopened. Create a follow-up task instead.';
128+
129+
/**
130+
* beforeUpdate (gated) — the showcase's one hook that REFUSES a write, with a
131+
* sentence addressed to the user.
132+
*
133+
* `throw new Error('<sentence>')` from a sandboxed body is the business-refusal
134+
* shape: the write is aborted (`onError: 'abort'`) and the sentence travels to
135+
* the caller as a 4xx — on `/data`, and through a script action's `ctx.api`
136+
* write on `/actions` too (`packages/runtime/src/sandbox/
137+
* nested-hook-refusal-is-a-rejection.test.ts`). `showcase_reopen_task`
138+
* (`src/ui/actions/index.ts`) is the script action that reaches it, and the
139+
* platform checklist item `records-forms.script-action-hook-refusal-toast`
140+
* drives that action in the console.
141+
*
142+
* The condition is the two-root transition form (same lesson as
143+
* {@link AuditTaskCompletionHook}): only the write that flips `done` from true
144+
* to not-true is refused. An edit of a finished task that leaves `done` alone
145+
* still lands.
146+
*/
147+
export const GuardTaskReopenHook = {
148+
name: 'showcase_guard_task_reopen',
149+
label: 'Guard Task Reopen',
150+
object: 'showcase_task',
151+
events: ['beforeUpdate'] as LifecycleEvent[],
152+
condition: 'previous.done == true && record.done != true',
153+
body: {
154+
language: 'js' as const,
155+
source: `throw new Error(${JSON.stringify(TASK_REOPEN_REFUSAL)});`,
156+
},
157+
priority: 60,
158+
onError: 'abort' as const,
159+
description: 'Refuses reopening a finished task (done true to false) with a user-facing sentence.',
160+
};
161+
122162
export const allHooks = [
123163
NormalizeTaskTitleHook,
124164
StampInquiryDefaultsHook,
165+
GuardTaskReopenHook,
125166
AuditTaskCompletionHook,
126167
WarnOverBudgetHook,
127168
];

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,9 @@ export const ShowcaseTranslationBundle = {
494494
label: '重算所选',
495495
successMessage: '已为整个选中集重算工时。',
496496
},
497+
// The hook-refusal specimen: every click is refused by
498+
// showcase_guard_task_reopen, so it carries no successMessage.
499+
showcase_reopen_task: { label: '重新打开' },
497500
},
498501
// Section headings of the six form-view projections in
499502
// `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick).

‎examples/app-showcase/src/ui/actions/index.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ export const MarkDoneAction = defineAction({
8484
refreshAfter: true,
8585
});
8686

87+
/**
88+
* script — the hook-REFUSAL specimen. Reopening a finished task is refused by
89+
* the `showcase_guard_task_reopen` beforeUpdate hook
90+
* (`src/data/hooks/index.ts`), so a click on this action always fails, by
91+
* design: the body's `ctx.api` write is aborted and the hook's sentence is what
92+
* the user must be told. The action route answers it as a 4xx carrying that
93+
* sentence, and the console owes exactly one error toast that contains it.
94+
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
95+
* drives this.
96+
*
97+
* Shown only on finished tasks — the only records the refusal applies to.
98+
*/
99+
export const ReopenTaskAction = defineAction({
100+
name: 'showcase_reopen_task',
101+
label: 'Reopen',
102+
icon: 'rotate-ccw',
103+
objectName: task,
104+
type: 'script',
105+
body: {
106+
language: 'js',
107+
source:
108+
"var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" +
109+
"if (!id) throw new Error('No record to reopen');" +
110+
"await ctx.api.object('showcase_task').update({ id: id, done: false });" +
111+
"return { ok: true, id: id };",
112+
capabilities: ['api.write'],
113+
},
114+
execution: 'perRecord',
115+
// #8990 — `has()` guards the sparse `list_item` face, as on Mark Done.
116+
visible: 'has(record.done) && record.done == true',
117+
// The task list's row menu only. `record_header` would be inert here: the
118+
// Task Detail page (`showcase_task_detail`, `kind: 'full'`) owns the whole
119+
// record layout and renders no header action bar.
120+
locations: ['list_item'],
121+
refreshAfter: true,
122+
});
123+
87124
/** url — navigate out, from the row overflow menu. */
88125
export const OpenDocsAction = defineAction({
89126
name: 'showcase_open_docs',
@@ -457,6 +494,7 @@ export const PortfolioSnapshotAction = defineAction({
457494

458495
export const allActions = [
459496
MarkDoneAction,
497+
ReopenTaskAction,
460498
OpenDocsAction,
461499
BulkReassignAction,
462500
QuickViewAction,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The showcase's hook-REFUSAL fixture: `showcase_guard_task_reopen` refuses the
5+
* write that reopens a finished task, with a sentence addressed to the user,
6+
* and `showcase_reopen_task` is the script action whose write reaches it.
7+
*
8+
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
9+
* drives this pair in the console (4xx on the action route, exactly one error
10+
* toast carrying the sentence, record unchanged). This file pins the two halves
11+
* the item stands on, so a run that FAILS is about the console and the action
12+
* route, never about a fixture that quietly stopped refusing:
13+
*
14+
* 1. on the real engine with the app's real hooks, the reopening write is
15+
* refused with the sentence and the stored row is unchanged — while an
16+
* edit of the same finished task that leaves `done` alone still lands;
17+
* 2. the action's body writes exactly the reopening shape (`done: false` on
18+
* its own record), so a click reaches the hook's condition.
19+
*
20+
* The action route's own 4xx for a nested sandboxed refusal is pinned in
21+
* `packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts`;
22+
* it is not restated here.
23+
*
24+
* Harness: the production one `hook-body-persisted-writes.test.ts` uses — real
25+
* `ObjectQL`, real `SqlDriver` (better-sqlite3), real `QuickJSScriptRunner`
26+
* behind `hookBodyRunnerFactory`, the app's real objects and hooks.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import {
33+
QuickJSScriptRunner,
34+
actionBodyRunnerFactory,
35+
hookBodyRunnerFactory,
36+
} from '@objectstack/runtime';
37+
38+
import { Account, Project, Task } from '../src/data/objects/index.js';
39+
import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js';
40+
import { ReopenTaskAction } from '../src/ui/actions/index.js';
41+
42+
const APP_ID = 'com.objectstack.showcase';
43+
const PACKAGE_ID = `app:${APP_ID}`;
44+
45+
const openEngines: ObjectQL[] = [];
46+
afterEach(async () => {
47+
while (openEngines.length) {
48+
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
49+
}
50+
});
51+
52+
async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
53+
const driver = new SqlDriver({
54+
client: 'better-sqlite3',
55+
connection: { filename: ':memory:' },
56+
useNullAsDefault: true,
57+
});
58+
await driver.connect();
59+
60+
const engine = new ObjectQL();
61+
openEngines.push(engine);
62+
engine.registerDriver(driver as never, true);
63+
await engine.init();
64+
for (const def of [Account, Project, Task]) {
65+
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
66+
}
67+
await engine.syncSchemas();
68+
engine.bindHooks(hooks as never[], {
69+
packageId: PACKAGE_ID,
70+
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }),
71+
});
72+
return engine;
73+
}
74+
75+
const ctx = { context: { userId: 'u_showcase', isSystem: true } };
76+
77+
const readBack = async (engine: ObjectQL, id: string) =>
78+
(await engine.find('showcase_task', { where: { id } }, ctx as never))[0] as any;
79+
80+
/** A finished task on a real project chain: done = true, progress = 100. */
81+
async function finishedTask(engine: ObjectQL): Promise<string> {
82+
const account: any = await engine.insert('showcase_account', { name: 'Initech', status: 'active' }, ctx as never);
83+
const project: any = await engine.insert(
84+
'showcase_project',
85+
{ name: 'Platform', account: String(account.id), status: 'planned' },
86+
ctx as never,
87+
);
88+
const task: any = await engine.insert(
89+
'showcase_task',
90+
{ title: 'Audit current IA', project: String(project.id), status: 'backlog' },
91+
ctx as never,
92+
);
93+
const id = String(task.id);
94+
await engine.update('showcase_task', { id, done: true, progress: 100 }, ctx as never);
95+
expect((await readBack(engine, id)).done).toBeTruthy();
96+
return id;
97+
}
98+
99+
/** Every string an error carries that a client could be shown. */
100+
function textOf(err: any): string {
101+
return [err?.message, err?.innerMessage, err?.cause?.message].filter(Boolean).join(' | ');
102+
}
103+
104+
describe('showcase_guard_task_reopen — the hook refuses reopening a finished task', () => {
105+
it('the reopening write is refused with the sentence, and the stored row is unchanged', async () => {
106+
const engine = await bootShowcase();
107+
const id = await finishedTask(engine);
108+
109+
const err = await engine
110+
.update('showcase_task', { id, done: false }, ctx as never)
111+
.then(() => null, (e: unknown) => e);
112+
113+
expect(err, 'expected the reopening write to be refused, but it resolved').not.toBeNull();
114+
expect(textOf(err)).toContain(TASK_REOPEN_REFUSAL);
115+
// The refusal is a business sentence, not a script fault: no native
116+
// error-class name leads it (`TypeError: …` is the fault shape #7543 keeps
117+
// off the wire).
118+
expect(String((err as any).innerMessage ?? '')).not.toMatch(/^(TypeError|ReferenceError|SyntaxError):/);
119+
120+
const stored = await readBack(engine, id);
121+
expect(stored.done).toBeTruthy();
122+
expect(stored.progress).toBe(100);
123+
}, 30000);
124+
125+
it('an edit of the finished task that leaves `done` alone still lands', async () => {
126+
// The two-root condition: `previous.done == true && record.done != true`.
127+
// A guard collapsed to `previous.done == true` would refuse this too and
128+
// make every finished task read-only.
129+
const engine = await bootShowcase();
130+
const id = await finishedTask(engine);
131+
132+
await engine.update('showcase_task', { id, priority: 'high' }, ctx as never);
133+
134+
const stored = await readBack(engine, id);
135+
expect(stored.priority).toBe('high');
136+
expect(stored.done).toBeTruthy();
137+
}, 30000);
138+
139+
it('REVERSE: with the hooks unbound the same write lands — the refusal is the hook', async () => {
140+
const engine = await bootShowcase([]);
141+
const id = await finishedTask(engine);
142+
143+
await engine.update('showcase_task', { id, done: false }, ctx as never);
144+
145+
expect((await readBack(engine, id)).done).toBeFalsy();
146+
}, 30000);
147+
});
148+
149+
describe('showcase_reopen_task — the script action writes the shape the hook refuses', () => {
150+
it("the body updates its own record with done: false and nothing else", async () => {
151+
let written: { object: string; data: Record<string, unknown> } | undefined;
152+
const ql = {
153+
object: (object: string) => ({
154+
update: async (data: Record<string, unknown>) => {
155+
written = { object, data };
156+
return { id: data.id };
157+
},
158+
}),
159+
};
160+
161+
const handler = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: 'showcase' })(
162+
ReopenTaskAction as never,
163+
);
164+
expect(typeof handler).toBe('function');
165+
166+
await handler!({
167+
recordId: 'task_1',
168+
record: { id: 'task_1', done: true, progress: 100 },
169+
params: {},
170+
user: { id: 'u1' },
171+
});
172+
173+
expect(written).toEqual({ object: 'showcase_task', data: { id: 'task_1', done: false } });
174+
});
175+
176+
it('is offered only on finished tasks', () => {
177+
expect(ReopenTaskAction.type).toBe('script');
178+
expect(ReopenTaskAction.execution).toBe('perRecord');
179+
const visible = ReopenTaskAction.visible as unknown;
180+
const source = typeof visible === 'string' ? visible : (visible as { source?: string }).source;
181+
expect(source).toBe('has(record.done) && record.done == true');
182+
});
183+
});

0 commit comments

Comments
 (0)