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
12 changes: 12 additions & 0 deletions .changeset/32134-synced-remedy-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/objectql': patch
'@objectstack/lint': patch
---

fix(objectql, lint): on a `managedBy: 'synced'` object, the stripped-`apiMethods` warning and the `object/managed-api-method-unaffordable` finding no longer tell the author to open a verb through `userActions`, which the parse refuses there (ADR-0153 D4, #32134)

Clause-②: no
Metadata-protocol: none

- `reconcileManagedApiMethods` (`@objectstack/objectql`): on a write-locked bucket (`MANAGED_BY_WRITE_LOCKED_BUCKETS`, today `synced` alone) the warning's remedy now reads "userActions cannot open a write on this bucket (ADR-0153 D4): remove [VERBS] from enable.apiMethods and send writes through a declared action (ADR-0153 D5)." Every other bucket keeps "Declare userActions to open a verb the object legitimately takes from a user context." The strip itself is unchanged.
- `object/managed-api-method-unaffordable` (`@objectstack/lint`): on `synced` the verdict says `userActions` cannot open a write there instead of "(needs userActions …)", and the fix line offers only the removal and a declared action. `os explain object/managed-api-method-unaffordable` gains a paragraph saying so. Every other bucket's verdict and fix line are unchanged, and the rule id, severity, `path` and what the rule refuses are unchanged.
3 changes: 3 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5516,6 +5516,9 @@ const MANAGED_API_METHOD_UNAFFORDABLE_EXPLANATION: RuleExplanation = {
'one edit. The fix line offers both ways out: drop the verbs, or open the affordance, and only ' +
'for a write a user context may really perform, once the guard that enforces it exists ' +
'(ADR-0092 D4: an affordance never ships ahead of its guard).',
'On a `synced` object `userActions` cannot open a write at all (ADR-0153 D4: the parse refuses ' +
'it), so the fix line offers only the removal, and a write goes through a declared action ' +
'(ADR-0153 D5).',
],
};

Expand Down
25 changes: 25 additions & 0 deletions packages/lint/src/validate-managed-api-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ describe('[#22161] one-line verdicts — object/managed-api-method-unaffordable'
expect(f.message).not.toContain('sys_environment');
});

// [ADR-0153 D4/D5] `userActions` cannot open a write on `synced` (the parse
// refuses it), so the verdict does not prescribe it; the platform-bucket
// case above is the control that keeps today's sentence.
it('on a synced object, the verdict says userActions cannot open a write, never "needs userActions"', () => {
const [f] = validateManagedApiMethods({
objects: [{ name: 'erp_order_copy', managedBy: 'synced', enable: { apiMethods: ['get', 'create', 'update'] } }],
});
expect(f.message).toBe(
"`enable.apiMethods` advertises [create, update], which `managedBy: 'synced'` refuses, and "
+ '`userActions` cannot open a write there, so the registry strips them',
);
});

it('`os explain` carries what the verdict no longer says', () => {
const entry = explainRule(MANAGED_API_METHOD_UNAFFORDABLE);
expect(entry, 'no `os explain object/managed-api-method-unaffordable` entry').toBeDefined();
Expand All @@ -141,6 +154,8 @@ describe('[#22161] one-line verdicts — object/managed-api-method-unaffordable'
'One finding per object, not per verb',
'ADR-0092 D4',
'Reads are never affordance-gated',
'ADR-0153 D4',
'ADR-0153 D5',
]) {
expect(text, `explanation names ${fact}`).toContain(fact);
}
Expand Down Expand Up @@ -194,6 +209,16 @@ describe('[#22161] one-line fix — object/managed-api-method-unaffordable', ()
);
});

it('on a synced object, the fix line offers only the removal and a declared action', () => {
const [f] = validateManagedApiMethods({
objects: [{ name: 'erp_order_copy', managedBy: 'synced', enable: { apiMethods: Object.keys(MANAGED_WRITE_VERB_AFFORDANCE) } }],
});
expect(f!.hint).toBe(
'Remove [create, update, upsert, delete, purge] from `enable.apiMethods`, and send writes through a declared action',
);
expect(f!.hint).not.toContain('userActions');
});

it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => {
const converted = fired.filter((f) => f.rule === MANAGED_API_METHOD_UNAFFORDABLE);
// Coverage control: one verb, several verbs and every verb fired.
Expand Down
23 changes: 16 additions & 7 deletions packages/lint/src/validate-managed-api-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
// error would stop the parse, and the predicate reads only authored keys
// (`managedBy`, `userActions`, `enable.apiMethods`) — no parse-time defaults.

import { checkManagedApiMethodAffordances } from '@objectstack/spec/data';
import { checkManagedApiMethodAffordances, MANAGED_BY_WRITE_LOCKED_BUCKETS } from '@objectstack/spec/data';

/**
* Stable diagnostic id. Named for the CONTRADICTION rather than for the strip,
Expand Down Expand Up @@ -96,6 +96,12 @@ export function validateManagedApiMethods(stack: unknown): ManagedApiMethodFindi

const verbs = conflicts.map((c) => c.verb).join(', ');
const flags = [...new Set(conflicts.map((c) => c.needs))];
const bucket = String(obj.def.managedBy);
// [ADR-0153 D4] On a write-locked bucket (`synced`) `userActions` cannot
// open a write — the parse refuses it — so the only way out is removal,
// and writes go through a declared action (D5). Every other bucket keeps
// both ways out.
const writeLocked = MANAGED_BY_WRITE_LOCKED_BUCKETS.has(bucket);
out.push({
severity: 'error',
rule: MANAGED_API_METHOD_UNAFFORDABLE,
Expand All @@ -106,12 +112,15 @@ export function validateManagedApiMethods(stack: unknown): ManagedApiMethodFindi
// why this refuses, and that an affordance opens only for a write a user
// context may really perform (ADR-0092 D4) are
// `os explain object/managed-api-method-unaffordable`.
message:
`\`enable.apiMethods\` advertises [${verbs}], which \`managedBy: '${String(obj.def.managedBy)}'\` ` +
`affordances refuse (needs userActions ${flags.join('/')}), so the registry strips them`,
hint:
`Remove [${verbs}] from \`enable.apiMethods\`, or add ` +
`\`userActions: { ${flags.map((f) => `${f}: true`).join(', ')} }\` once a guard enforces the write`,
message: writeLocked
? `\`enable.apiMethods\` advertises [${verbs}], which \`managedBy: '${bucket}'\` refuses, and ` +
`\`userActions\` cannot open a write there, so the registry strips them`
: `\`enable.apiMethods\` advertises [${verbs}], which \`managedBy: '${bucket}'\` ` +
`affordances refuse (needs userActions ${flags.join('/')}), so the registry strips them`,
hint: writeLocked
? `Remove [${verbs}] from \`enable.apiMethods\`, and send writes through a declared action`
: `Remove [${verbs}] from \`enable.apiMethods\`, or add ` +
`\`userActions: { ${flags.map((f) => `${f}: true`).join(', ')} }\` once a guard enforces the write`,
});
}

Expand Down
43 changes: 43 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,49 @@ describe('reconcileManagedApiMethods', () => {
}
});

// [ADR-0153 D4/D5] The remedy branches on the bucket: `userActions` cannot
// open a write on `synced` (the parse refuses it), so the warning names the
// verbs to drop and points writes at a declared action instead.
it('on a synced object, the warning names the verbs to drop and a declared action, never userActions', () => {
const warn = vi.fn<(msg: string) => void>();
reconcileManagedApiMethods(
{
name: 'erp_order_copy',
managedBy: 'synced',
enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] },
} as ServiceObject,
{ warn },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toBe(
`[Registry] Object "erp_order_copy" is managedBy:'synced' but advertised generic write verb(s) ` +
`[create, update] in enable.apiMethods its resolved affordances do not permit — stripping them ` +
`(ADR-0092/ADR-0103). userActions cannot open a write on this bucket (ADR-0153 D4): remove ` +
`[create, update] from enable.apiMethods and send writes through a declared action (ADR-0153 D5). ` +
`Kept: [get, list]. \`os lint\` reports the same contradiction as ` +
`\`object/managed-api-method-unaffordable\` with full authoring context.`,
);
});

it('control: every other bucket keeps the userActions sentence unchanged', () => {
const warn = vi.fn<(msg: string) => void>();
reconcileManagedApiMethods(
{
name: 'sys_automation_run',
managedBy: 'engine-owned',
enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] },
} as ServiceObject,
{ warn },
);
expect(warn.mock.calls[0][0]).toBe(
`[Registry] Object "sys_automation_run" is managedBy:'engine-owned' but advertised generic write verb(s) ` +
`[create, update] in enable.apiMethods its resolved affordances do not permit — stripping them ` +
`(ADR-0092/ADR-0103). Declare userActions to open a verb the object legitimately takes from a ` +
`user context. Kept: [get, list]. \`os lint\` reports the same contradiction as ` +
`\`object/managed-api-method-unaffordable\` with full authoring context.`,
);
});

it('twin: a synced object advertising reads only is left as it is', () => {
const warn = vi.fn();
const readOnly: any = {
Expand Down
15 changes: 11 additions & 4 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, resolveInjectedColumnProvenance as injectedColumnProvenance, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences, BASE_FIELD_DISPLAY_WHITELIST, type ObjectExtensionParsed } from '@objectstack/spec/data';
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, resolveInjectedColumnProvenance as injectedColumnProvenance, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, MANAGED_BY_WRITE_LOCKED_BUCKETS, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences, BASE_FIELD_DISPLAY_WHITELIST, type ObjectExtensionParsed } from '@objectstack/spec/data';
// [#4513] The audit-family governance table, and [#6562] the injected-column
// DEFINITION tables it governs — see the re-exports below for why both live in a
// package `objectql` and `metadata-protocol` both depend on.
Expand Down Expand Up @@ -2011,12 +2011,19 @@ export function reconcileManagedApiMethods(
const kept = methods.filter((_m, i) => !strippedIndexes.has(i));
const stripped = conflicts.map((c) => c.verb);

const managedBy = (schema as any).managedBy;
// [ADR-0153 D4] On a write-locked bucket (`synced`) `userActions` cannot open
// a write — the parse refuses it — so the remedy is to drop the verbs and
// write through a declared action (D5). Every other bucket keeps its sentence.
const remedy = MANAGED_BY_WRITE_LOCKED_BUCKETS.has(managedBy)
? `userActions cannot open a write on this bucket (ADR-0153 D4): remove [${stripped.join(', ')}] ` +
`from enable.apiMethods and send writes through a declared action (ADR-0153 D5).`
: `Declare userActions to open a verb the object legitimately takes from a user context.`;
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
warn(
`[Registry] Object "${schema.name}" is managedBy:'${(schema as any).managedBy}' but advertised ` +
`[Registry] Object "${schema.name}" is managedBy:'${managedBy}' but advertised ` +
`generic write verb(s) [${stripped.join(', ')}] in enable.apiMethods its resolved affordances ` +
`do not permit — stripping them (ADR-0092/ADR-0103). Declare userActions to open a verb the ` +
`object legitimately takes from a user context. Kept: [${kept.join(', ')}]. ` +
`do not permit — stripping them (ADR-0092/ADR-0103). ${remedy} Kept: [${kept.join(', ')}]. ` +
`\`os lint\` reports the same contradiction as \`object/managed-api-method-unaffordable\` ` +
`with full authoring context.`,
);
Expand Down
Loading