Skip to content

Commit 396fcb9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24710-ownership-refusal-text
2 parents f90ea26 + 54159fd commit 396fcb9

25 files changed

Lines changed: 1573 additions & 119 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The `source` describe on `MetadataEventSchema` no longer lists `"fs"`, `"studio"`, `"rest"`, `"ai"` and
8+
`"git-import"` as origin labels: of those, only `fs` is written by any producer (`FileSystemRepository`),
9+
and no write path stamps an authoring surface there. It now says what the field is: the write path that
10+
produced the event, an open label its writer stamps rather than an enum to pick from or match against, for
11+
example `protocol.saveMetaItem`, `protocol.publishMetaItem`, `migrate-stored`, `sys-metadata-repo`
12+
(`SysMetadataRepository`'s default) or `fs`. The field stays `z.string()`, so no key, type, export or
13+
accepted value changes, and every repository emits exactly what it emitted before.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(objectql,lint)!: a `conditional` validation rule's `when` reads one hop through a reference, the way a rule's `condition` does
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape, export or authorable spelling is added, removed or renamed, so there is nothing for `objectstack migrate meta` to rewrite. A rule keeps its authored shape. What changes is how a `conditional` rule's `when` is judged: the engine now serves it one hop through a reference (it read the bare id before), and the validation-rule pass judges its hops as it judges a `condition`'s. Both narrow: the save door refuses four `when` shapes it accepted, and the runtime refuses a write it judged on an empty reference under a null-safe `when`. -->
11+
12+
**BREAKING** (an accept-set narrowing, at the save door and at write time), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
A `conditional` rule's `when` that reads through a lookup (`record.account.status == 'active'`) now works. The engine loads the related row for it, as it does for a `script` or `cross_field` rule's `condition`, and `when` picks `then` or `otherwise` on that row. Before, `when` met the stored id, so the read faulted and every write the rule guarded was refused with "The when-predicate reads 'status', which this object does not declare". A `when` nested in a `then` or `otherwise` is served the same way. A sibling rule keeps its own verdict when a `when` cannot be evaluated.
15+
16+
`@objectstack/objectql`, at write time:
17+
18+
- FROM: `when: record.account.status == 'active'` refused every write it guarded as unevaluable. TO: it is judged on the related row.
19+
- FROM: a null-safe spelling (`record.account.?status.orValue('') == 'active'`, `has(record.account.status) && …`) read the stored id and silently took `otherwise` for every account. TO: it is judged on the related row.
20+
- FROM: on a write whose reference is empty, a null-safe `when`, or one that tests `record.account != null` in the same expression, evaluated to false and judged `otherwise`. TO: the write is refused (`400 VALIDATION_FAILED`, `_record` `rule_violation`), as a `condition` reading through an empty reference is. The refusal names the guard.
21+
- A `when` that reads a second hop, a field the related object does not declare, or the reference both through it and as a value is refused with a sentence that names the related object and field, instead of the generic one. A `when` that faults on a delete's reference cleanup names the delete, as a `condition` does.
22+
- The guard spelling `when: record.account != null` reads the stored id, as before.
23+
24+
`@objectstack/lint`, where a rule is written: `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. They now judge a `when`, top level and nested, with the checks a `condition` gets. Each of these saved before (`200`) and is refused now (`422 INVALID_METADATA` at the save door), located at the `when` slot. At run time each refused every write it guarded, and the third also took `otherwise` on an empty reference:
25+
26+
- FROM: a second hop (`record.account.owner.email != null`), saved. TO: refused.
27+
- FROM: a field the target does not declare (`record.account.region == 'north'`), saved. TO: refused, "unknown field `region` on `<target>`".
28+
- FROM: the reference read both through it and as a value (`record.account != null && record.account.status == 'active'`), saved. TO: refused, naming the guard.
29+
- FROM: a hop into a stored-metadata-body table, saved. TO: refused.
30+
31+
What to do. To skip a rule while the reference is empty, keep the reference test in its own `when` and nest the rule that reads through it: a `conditional` whose `when` is `record.account != null`, with your `conditional` as its `then`. To refuse an empty reference, make the field `required: true`. Read a field the target declares, and read one hop.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
fix(plugin-audit, plugin-sharing): record pages, the `by_object` grouping and the Config list no longer name the target object by its API name
7+
8+
Clause-②: no
9+
10+
- **A record share's title is its recipient.** `sys_record_share` declares `nameField: 'recipient_id'`. It declared no title pointer, so the registry derived `object_name` (the first `*_name` text field), and a record share's page heading read the shared record's object API name, such as `showcase_announcement`. Its `titleFormat` is removed: the declared pointer outranks it everywhere.
11+
- **A share link's title is its label, else what it grants.** `sys_share_link` declares `nameField: 'label'`, the field the registry already derived. A link with no label is titled by `titleFormat: '{permission} · {audience}'` (for example "View · Anyone with the link") instead of `{object_name}/{record_id} ({permission})`.
12+
- **`sys_audit_log` drops its `titleFormat`.** It read `{action} · {object_name}`, but the required `action` pointer outranks it, so it never titled a row.
13+
- **The strip lists neither half of the pointer pair.** `highlightFields` no longer lists `object_name` or `record_id` on the three objects. The record page's strip drew them as stored text, an API name and a raw id. A strip field is also hidden from the Details tab, which is where `record_id` draws as the record card, headed by the target object's label.
14+
- **`sys_record_share.by_object` sorts by object instead of grouping on it.** A group header drew `object_name` as stored text. The sort still keeps one object's grants together.
15+
- **`sys_audit_log.config_changes` drops its Object column.** Its rows carry no `record_id`, so the column drew a stored API name (`sys_setting`, `sys_platform_setting` or `sys_user`) with no record beside it.
16+
- **Unchanged:** `object_name` stays a declared field on all three objects, and the pointer pair, its indexes and every query on it are untouched. On the record page, the Details tab still shows `object_name` as a row of its stored text.
17+
18+
A patch: title and highlight declarations and list view keys change on shipped platform objects. No export, type, accepted value, stored column or authorable key changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot
6+
7+
Clause-②: no (a replay comparison: no accept set, served body or masking verdict moves; the loader takes no new read and never sees a stored credential)
8+
9+
- **What changed.** A replay reads the existing rows through `engine.find`, which serves a set `secret` field, and a set `password` field on an object not managed by better-auth, as `SECRET_MASK` in every context (ADR-0100). The replay compared the seed's plaintext with that mask, read the row as changed and rewrote it on every boot: `updated_at` moved, lifecycle validation ran again and history could record an edit nobody made. A field the object declares masked on read (asked of `isMaskedOnReadFieldType`, the same declaration the engine's own mask reads) now never blocks the skip while the existing row reads back the mask.
10+
- **What a seed author sees.** A seed change to ONLY such a field is not applied on replay. Change another field of the same row as well (the rewrite then writes the whole seeded record, the new credential included), or rebuild the database. Setting a credential on a row that has none, or clearing one, is still written: an unset credential reads back `null`, not the mask. A load that skipped a row without comparing a credential says so in one `info` line naming the fields and both remedies.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a `runAs: 'user'` hook now carries the acting user's email, so a `current_user.email` row policy admits it as it admits the same user's own request
6+
7+
A hook declaring `runAs: 'user'` is pinned to the triggering user, and the pinned context is seeded with the user's grants and organization peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`). That seed never took the user's email, although the same resolution returns it. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals status mirror and a `runAs: 'system'` flow's writes are shipped producers), the pinned context carried no `email`. Every row policy keyed on `current_user.email` (for example `assignee == current_user.email`) therefore dropped out, and the hook's `ctx.api` reads returned no rows where the same user's request returns theirs. It failed closed: rows were hidden, never leaked.
8+
9+
The seed now carries the email too:
10+
11+
- A pinned context with no `email` takes the user's stored email (`sys_user.email`), from the user-row read the resolution already makes, which is the fallback the request path uses.
12+
- An email the context already carries, such as a session's on a request-resolved context, is kept exactly as it was.
13+
- A context that carries `permissions` and a peer list but no email is no longer handed through unseeded: it resolves once and takes the email alone. Its positions, permission sets and peer list stay exactly as they were.
14+
- A user with no stored email (a phone-only sign-up) still carries none, and email-keyed policies still drop out for them, as on their own request.
15+
16+
`runAs: 'inherit'` (the default) and `runAs: 'system'` are unchanged, and a `runAs: 'user'` hook whose trigger names no user is still refused with `HOOK_UNSCOPED_DATA_ACCESS`. A `runAs: 'user'` hook fired from a context that lacks the email now pays one grants resolution per dispatch, as one that lacks either list already did. If the resolver fails, the hook runs on the unseeded pin and a warning names the policies that drop out.
17+
18+
Clause-②: no

‎packages/lint/src/runtime-gate.object-validation-writes.test.ts‎

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
* down, in `os build` and at this door alike. The pass now runs the same
4040
* `check()` on every nested predicate, at the location the null-guard gate
4141
* already gives it (`validation rule 'outer' then → 'inner'`), with the
42-
* relationship-traversal checks on a nested `condition` (ObjectQL hydrates it)
43-
* and not on a nested `when` (it does not). The second describe block below
42+
* relationship-traversal checks on a nested `condition` and — since #24512 —
43+
* on a nested `when` (ObjectQL hydrates both). The second describe block below
4444
* pins it; its protocol half is the #22042 block of the same protocol file.
4545
*/
4646
import { describe, expect, it } from 'vitest';
@@ -236,7 +236,7 @@ const fxRef = (validations: unknown[]) => {
236236
const base = fxRule(validations);
237237
return { ...base, fields: { ...base.fields, account: { type: 'lookup', label: 'Account', reference: 'fx_rule' } } };
238238
};
239-
/** Reads more than one relationship hop — a shape `checkPredicate` refuses, and `checkConditional` never judges. */
239+
/** Reads more than one relationship hop — a shape `checkPredicate` and (#24512) `checkConditional` refuse. */
240240
const MULTI_HOP = 'record.account.owner.email != null';
241241
const HYDRATION = {
242242
type: 'conditional',
@@ -311,16 +311,21 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
311311
]);
312312
});
313313

314-
it('the traversal checks follow the evaluator per slot: ON for a nested `condition`, OFF for a nested `when`', () => {
314+
it('the traversal checks follow the evaluator per slot: ON for a nested `condition` and, since #24512, a nested `when`', () => {
315315
const atBuild = buildFindings(fxRef([HYDRATION]));
316316

317-
// `checkPredicate` refuses a read deeper than one hop at any depth, so the build says so…
318-
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_rule' · validation rule 'outer' then → 'inner'"]);
319-
expect(atBuild[0]!.message).toContain('ONE hop');
320-
// …and `checkConditional` never hydrates a `when`, so the same source there earns no
321-
// traversal prescription — exactly as the top-level `when` site is opted out.
317+
// `checkPredicate` refuses a read deeper than one hop at any depth, and
318+
// `checkConditional` evaluates a `when` on the same hydrated scope, so the
319+
// build says so for both slots…
320+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([
321+
"object 'fx_rule' · validation rule 'outer' then → 'inner'",
322+
"object 'fx_rule' · validation rule 'outer' otherwise → 'mid' when-predicate",
323+
]);
324+
for (const f of atBuild) expect(f.message).toContain('ONE hop');
325+
// …and at the top-level `when` site too.
322326
const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }]));
323-
expect(topWhen, dump(topWhen)).toEqual([]);
327+
expect(topWhen.map((f) => f.where), dump(topWhen)).toEqual(["object 'fx_rule' · validation 'outer' when"]);
328+
expect(topWhen[0]!.message).toContain('ONE hop');
324329
expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild);
325330
});
326331

@@ -382,3 +387,68 @@ describe('#24468 — the object door refuses a rule hop to a field the target do
382387
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
383388
});
384389
});
390+
391+
describe('#24512 — the object door judges a `conditional` rule\'s `when` hop as it judges a `condition`\'s', () => {
392+
/** The lookup's target, delivered by ANOTHER package the written one depends on. */
393+
const account = {
394+
name: 'fx_account', label: 'Account', sharingModel: 'private', _packageId: 'com.example.accounts',
395+
fields: { status: { type: 'text', label: 'Status' } },
396+
};
397+
const order = (validations: unknown[]) => ({
398+
name: 'fx_order',
399+
label: 'Order',
400+
sharingModel: 'private',
401+
fields: {
402+
amount: { type: 'number', label: 'Amount' },
403+
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
404+
},
405+
validations,
406+
});
407+
const gate = (when: string, name = 'gate') => ({
408+
type: 'conditional', name, when, message: 'x',
409+
then: { type: 'script', name: 'cap', condition: 'record.amount != null && record.amount > 100', message: 'y' },
410+
});
411+
const gateOrder = (validations: unknown[]) => runRuntimeAuthoringRules({
412+
type: 'object',
413+
item: order(validations),
414+
context: { objects: [account] },
415+
packageScope: { packageId: 'com.example.orders', dependencies: ['com.example.accounts'] },
416+
});
417+
418+
it('the fixtures are spec-valid: each refusal below is the expression verdict, not the schema\'s', () => {
419+
for (const when of ["record.account.region == 'north'", 'record.account.owner.email != null', "record.account.status == 'open'"]) {
420+
const parsed = ObjectSchema.safeParse(order([gate(when), { type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x', then: gate(when, 'inner') }]));
421+
expect(parsed.success, dump(parsed.error?.issues)).toBe(true);
422+
}
423+
});
424+
425+
it('⭐ LIT — a `when` reading a field the target does not declare is REFUSED under EXPRESSION_INVALID, naming the slot, the field and the target', () => {
426+
const result = gateOrder([gate("record.account.region == 'north'")]);
427+
428+
const errs = expressionFindings(result.errors);
429+
expect(errs, dump(result)).toHaveLength(1);
430+
const where = "object 'fx_order' · validation 'gate' when";
431+
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where, path: where });
432+
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
433+
expect(errs[0]!.message).toContain('the rule has no verdict');
434+
});
435+
436+
it('⭐ LIT — a `when` reading a second hop is REFUSED, and a nested `when` is judged too', () => {
437+
const result = gateOrder([{
438+
type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x',
439+
then: gate('record.account.owner.email != null', 'inner'),
440+
}]);
441+
442+
const errs = expressionFindings(result.errors);
443+
expect(errs.map((e) => e.where), dump(result)).toEqual(["object 'fx_order' · validation rule 'outer' then → 'inner' when-predicate"]);
444+
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' });
445+
expect(errs[0]!.message).toContain('ONE hop');
446+
});
447+
448+
it('⭐ CONTROL — a `when` reading a field the target declares publishes clean', () => {
449+
const result = gateOrder([gate("record.account.status == 'open'")]);
450+
451+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
452+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
453+
});
454+
});

0 commit comments

Comments
 (0)