Skip to content

Commit 7df7c64

Browse files
authored
feat(sharing): criteria_json is declaratively required — ADR-0113 P2, the first consumer (P1 browser-verified) (#4044)
The field ADR-0113 was written for, cashing in the split #4031 landed. sys_sharing_rule.criteria_json flips to required: true — the posture that was un-declarable before the split: write contract only (insert must provide, update may not null out, rows predating the gate rest, an admin can still active:false an over-broad legacy rule), deliberately NO storage.notNull because deployed tenants' legacy null rows are the case the split exists for. The #3929 guards consolidate by layer: the declaration owns missing/null/explicit-clear plus the Setup form's marker and client validation; bindRuleCriteriaGuard narrows to the non-null match-all shapes required cannot express ('{}', vacuous $and/$or, unparsable JSON — the shape-naming message still wins, hooks fire before validation); defineRule keeps the API seam; the evaluator stays fail-closed (ADR-0049). Empty-state registry entry carries the declarative pointer. P1 verified in the browser with zero Console changes (objectui form-view bridge is already metadata-driven): the served metadata carries criteria_json.required: true, and the Setup sharing-rule form renders Criteria* — positive control Object*, negative control Description. The dogfood legacy-row fixture moved to the driver seam: with the declaration in place, unbinding the hook no longer fakes a pre-gate row through the engine — the defense-in-depth the flip bought, caught by CI. plugin-sharing 198/198; the bound sharing-rule-criteria-required proof 5/5 against a booted stack; check:empty-state + check:liveness green; patch changeset (not breaking — the same writes were already rejected by the hook).
1 parent 0af50a3 commit 7df7c64

5 files changed

Lines changed: 62 additions & 36 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
feat(sharing): `sys_sharing_rule.criteria_json` is declaratively required (ADR-0113 P2)
6+
7+
The field the ADR was written for: `required: true` as the write contract —
8+
insert must provide, update may not null out, legacy null rows rest, an admin
9+
can still `active: false` an over-broad legacy rule. Deliberately NO
10+
`storage.notNull`: deployed tenants' legacy nulls are the case the split
11+
exists for. The Setup form's required marker and client validation now derive
12+
from the declaration.
13+
14+
Not breaking: a rule without criteria was already rejected by the #3929 hook
15+
guard; the guard narrows to the non-null match-all shapes `required` cannot
16+
express ('{}', vacuous $and/$or, unparsable JSON), `defineRule` keeps the API
17+
seam, and the evaluator stays fail-closed (ADR-0049).

packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,18 @@ export const SysSharingRule = ObjectSchema.create({
136136

137137
criteria_json: Field.textarea({
138138
label: 'Criteria',
139-
// [#3896] Mandatory in substance, `required: false` in metadata: the
140-
// column is nullable in every already-deployed tenant (rows predating
141-
// this gate), and flipping it to `required` would only translate into a
142-
// destructive NOT NULL migration that those nulls block. The invariant
143-
// is enforced where it can also explain itself — `bindRuleCriteriaGuard`
144-
// fails the INSERT, and `defineRule` fails the API call.
145-
required: false,
139+
// [#3896 → ADR-0113] Mandatory in substance AND now in metadata. Under
140+
// the old tri-binding this was un-declarable — `required: true` implied
141+
// a NOT NULL migration that every deployed tenant's legacy null rows
142+
// block. ADR-0113 split the knob: this is the WRITE contract only (an
143+
// insert must provide it, an update may not null it out, rows predating
144+
// the gate rest — an admin can still `active: false` an over-broad
145+
// legacy rule). Deliberately NO `storage.notNull`: those legacy nulls
146+
// are exactly the case the split exists for. The declaration is the
147+
// first line; `bindRuleCriteriaGuard` still rejects the NON-null
148+
// match-all shapes `required` cannot express, and `defineRule` guards
149+
// the API path with the shape-naming message.
150+
required: true,
146151
// Rendered as a visual criteria builder scoped to the selected object's
147152
// fields (dependsOn: object_name), storing the same JSON FilterCondition.
148153
// An "Edit as JSON" fallback keeps hand-authored / advanced filters

packages/plugins/plugin-sharing/src/rule-hooks.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,17 @@ export function unbindAllRuleHooks(engine: MinimalEngine): number {
9090
* (ADR-0078). Failing the write instead tells the admin the criteria is
9191
* missing while they are still looking at the form.
9292
*
93-
* Update semantics are deliberately narrower than insert: only a patch that
94-
* SUPPLIES `criteria_json` is checked. An existing row left over from before
95-
* this guard has a null criteria, and an admin must still be able to
96-
* `active: false` it — demanding a criteria to switch off an over-broad rule
97-
* would be exactly backwards.
93+
* LAYERING since ADR-0113: the field now declares `required: true` (write
94+
* contract), so the record validator already rejects a missing/null criteria
95+
* on insert and an explicit null-out on update — with the same non-regression
96+
* update semantics this hook pioneered (only a patch that SUPPLIES
97+
* `criteria_json` is checked; a legacy null row can still be `active: false`d).
98+
* This hook remains for what `required` cannot express: the NON-null
99+
* match-all shapes — `'{}'`, an all-vacuous `$and`/`$or`, unparsable JSON, a
100+
* bare scalar — where `isMatchAllCriteria` is the judge and the rejection
101+
* names the offending shape instead of saying "required". Defense in depth on
102+
* the null cases costs one property check and keeps this guard's better
103+
* message on paths where hooks fire before validation.
98104
*/
99105
export function bindRuleCriteriaGuard(engine: MinimalEngine, logger?: MinimalLogger): void {
100106
if (typeof engine.registerHook !== 'function') return;

packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
3131
const RULES = '/sharing/rules';
3232
const SYS = { isSystem: true } as const;
3333
const OBJECT = 'showcase_project';
34-
/** Package id of the sys_sharing_rule criteria guard (plugin-sharing). */
35-
const GUARD_PACKAGE = 'plugin-sharing:rule-criteria-guard';
3634

3735
describe('#3896 — a sharing rule POSTed without criteria is rejected, never made match-all', () => {
3836
let stack: VerifyStack;
@@ -129,27 +127,27 @@ describe('#3896 — a sharing rule POSTed without criteria is rejected, never ma
129127
});
130128

131129
it('a criteria-less rule already IN the table shares nothing and loses its grants', async () => {
132-
// Reproduce a row from BEFORE the guard existed: drop the guard hooks,
133-
// write the row, put them back. Nothing else can produce this shape now —
134-
// which is the point of the previous test.
130+
// Reproduce a row from BEFORE the gate existed. Since ADR-0113 P2 the
131+
// engine path is DOUBLY closed — the hook guard and the declarative
132+
// `required: true` (record validator) — so unbinding the hook is no longer
133+
// enough to author this shape through the engine, which is exactly the
134+
// defense-in-depth the flip bought. A true legacy row predates all of it:
135+
// write it at the DRIVER seam, the same level a pre-gate deployment's
136+
// storage actually holds it.
135137
const id = 'srule_3896_legacy';
136-
ql.unregisterHooksByPackage(GUARD_PACKAGE);
137-
try {
138-
await ql.insert('sys_sharing_rule', {
139-
id,
140-
name: 'legacy_match_all_3896',
141-
label: 'Legacy match-all',
142-
object_name: OBJECT,
143-
criteria_json: null,
144-
recipient_type: 'position',
145-
recipient_id: 'sales_rep',
146-
access_level: 'read',
147-
active: true,
148-
}, { context: SYS });
149-
} finally {
150-
const { bindRuleCriteriaGuard } = await import('@objectstack/plugin-sharing');
151-
bindRuleCriteriaGuard(ql);
152-
}
138+
const driver = (ql as any).getDriverForObject('sys_sharing_rule');
139+
expect(driver, 'sys_sharing_rule driver must be resolvable').toBeTruthy();
140+
await driver.create('sys_sharing_rule', {
141+
id,
142+
name: 'legacy_match_all_3896',
143+
label: 'Legacy match-all',
144+
object_name: OBJECT,
145+
criteria_json: null,
146+
recipient_type: 'position',
147+
recipient_id: 'sales_rep',
148+
access_level: 'read',
149+
active: true,
150+
});
153151

154152
const res = await stack.apiAs(admin, 'POST', `${RULES}/${id}/evaluate`, {});
155153
expect(res.status).toBeLessThan(300);

packages/spec/scripts/liveness/empty-state-registry.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [
103103
property: 'condition',
104104
semantics: 'closed',
105105
rationale:
106-
"The #3896 outcome, recorded so the answer is findable: a sharing rule's criteria is REQUIRED, and a rule that reaches storage without one grants nothing. There is no 'share every record' rule — object-wide read is the object's organization-wide default (`sharingModel`); the match-all shape only ever existed as a failure mode. Carries no permissive statement to scan (that is what being closed means), so it is exempt from the staleness check and exists purely as the catalogue answer to 'what does an empty criteria do?'.",
107-
evidence: 'packages/spec/src/security/sharing.zod.ts',
106+
"The #3896 outcome, recorded so the answer is findable: a sharing rule's criteria is REQUIRED, and a rule that reaches storage without one grants nothing. There is no 'share every record' rule — object-wide read is the object's organization-wide default (`sharingModel`); the match-all shape only ever existed as a failure mode. Since ADR-0113 the requirement is DECLARATIVE too: `sys_sharing_rule.criteria_json` carries `required: true` (write contract — legacy null rows rest, no NOT NULL migration), with the hook guard narrowed to the non-null match-all shapes `required` cannot express. Carries no permissive statement to scan (that is what being closed means), so it is exempt from the staleness check and exists purely as the catalogue answer to 'what does an empty criteria do?'.",
107+
evidence: 'packages/spec/src/security/sharing.zod.ts; packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts (criteria_json required: true, ADR-0113); packages/plugins/plugin-sharing/src/rule-hooks.ts (shape guard)',
108108
},
109109

110110
{

0 commit comments

Comments
 (0)