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
49 changes: 49 additions & 0 deletions .changeset/managed-apimethods-affordance-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/objectql": patch
---

feat(spec,lint): gate `enable.apiMethods` ⊆ affordances at authoring time, not only in the boot log (#7521)

A `managedBy` object that advertises a generic write verb in `enable.apiMethods`
while its own resolved affordances refuse that write is internally
contradictory — ADR-0049's `declared != enforced` class, stated entirely within
one object's declaration. `reconcileManagedApiMethods` (objectql's registry) has
always caught it at registration and **stripped** the verb, so nothing was ever
exposed. What it could not do is tell anyone: the only signal was a
`console.warn`.

`sys_environment` and `sys_package` declared
`apiMethods: ['get','list','create','update']` against `userActions` that refused
all three writes. The strip and its warning fired on **every control-plane boot
for the life of the divergence and nobody noticed** — the split was eventually
found by hand-driving the HTTP seam while writing something unrelated, not by
any gate. A boot log is not an authoring surface: it is read after an incident,
by an operator, in a repo whose author has long since moved on.

**New rule — `object/managed-api-method-unaffordable` (`error`).** `os lint`,
`os validate` and `os build` now report the contradiction where the author is
standing, naming the refused verbs, the `userActions` flags that would be needed
and both ways out. It runs pre-parse, so the finding survives an unrelated schema
error elsewhere in the stack.

**One predicate, two consumers.** The judgement moved to
`checkManagedApiMethodAffordances` in `@objectstack/spec/data` — beside
`resolveCrudAffordances`, the affordance authority both sides already read — and
the registry's strip is now a pure reaction to it. That is the point rather than
a tidy-up: a second copy of this table at either consumer would *be* the
declared≠enforced drift the rule exists to detect. Same shape, and same reason,
as `checkFieldCompleteness` under ADR-0078.

**Boot behaviour is deliberately unchanged.** `reconcileManagedApiMethods` still
warns and strips; it does not throw. Failing registration closed would let one
metadata typo kill a control-plane boot, which is too harsh for ops — the
author-time gate is where this blocks. The boot warning now cites the lint rule
id, so an operator who greps a stripped verb out of a log lands on the gate.

Also exported: `validateManagedApiMethods` and `MANAGED_API_METHOD_UNAFFORDABLE`
from `@objectstack/lint`. A repo whose object definitions live in **code** — which
`os lint` never walks — can run the same rule over its own registry instead of
hand-rolling the affordance table, which is what every such repo has had to do
until now.
26 changes: 26 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,6 +100,7 @@
import { validateStackExpressions } from './validate-expressions.js';
import { validateListViewMode } from './validate-list-view-mode.js';
import { validateFunctionalCompleteness } from './validate-functional-completeness.js';
import { validateManagedApiMethods } from './validate-managed-api-methods.js';
import { validateViewContainers } from './validate-view-containers.js';
import { validateWidgetBindings } from './validate-widget-bindings.js';
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
Expand DownExpand Up@@ -432,6 +433,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
run: (stack) => validateFunctionalCompleteness(stack),
},
// [#7521, via cloud#1225] A managed object advertising a generic write verb
// in `enable.apiMethods` that its own resolved affordances refuse. Every key
// is one we know and each is individually valid, so #4001's unknown-key
// rejection and the Zod parse both pass it; the contradiction is only visible
// when the two keys are read TOGETHER, which nothing did at authoring time.
//
// `gating` because the declaration is already false when it ships: objectql's
// registry strips the verb at registration, so the metadata advertises an API
// the product does not serve. That strip has been correct and silent — a
// `console.warn` on every control-plane boot that went unread for the life of
// a real divergence (`sys_environment`/`sys_package`). This entry is the
// ruling's "close it where the author is"; boot stays warn-and-strip.
//
// Pre-parse: the predicate reads only authored keys, and the finding must
// survive an unrelated schema error elsewhere in the stack.
{
name: 'validateManagedApiMethods',
tier: 'gating',
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-managed-api-methods.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
run: (stack) => validateManagedApiMethods(stack),
},
// A view container in `views: []` that registers zero views: nothing appears
// in the Console, and the schema step cannot tell it from an intentionally
// empty one. The FLAT-list-view arm no longer needs this tier — `ViewSchema`
Expand Down
11 changes: 11 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,17 @@ export type {
FunctionalCompletenessFinding,
FunctionalCompletenessSeverity,
} from './validate-functional-completeness.js';
// [#7521] The managed-object `apiMethods` ⊆ affordances gate. All judgement
// lives in the shared predicate in `@objectstack/spec/data`, which objectql's
// `reconcileManagedApiMethods` reads too — so the boot-time strip and this
// author-time gate cannot reach different verdicts. Exported so a repo that
// ships object definitions in CODE (which `os lint` never walks) can run the
// same rule over its own registry, instead of hand-rolling the table.
export {
validateManagedApiMethods,
MANAGED_API_METHOD_UNAFFORDABLE,
} from './validate-managed-api-methods.js';
export type { ManagedApiMethodFinding } from './validate-managed-api-methods.js';
export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js';
export {
validateFlowTriggerReadiness,
Expand Down
101 changes: 101 additions & 0 deletions packages/lint/src/validate-managed-api-methods.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #7521 — the authoring-time gate for `enable.apiMethods` ⊆ affordances.
//
// The predicate's own verdict table is tested in `@objectstack/spec`
// (`managed-api-affordance.test.ts`); what is tested HERE is the walk — which
// stack shapes are reached, and whether a conflict becomes a finding an author
// can act on.

import { describe, expect, it } from 'vitest';

import {
MANAGED_API_METHOD_UNAFFORDABLE,
validateManagedApiMethods,
} from './validate-managed-api-methods';

/** The #7521 shape: `platform` bucket, `userActions` closing every write. */
const sysEnvironment = {
name: 'sys_environment',
managedBy: 'platform',
userActions: { create: false, edit: false, delete: false },
enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] },
};

describe('validateManagedApiMethods — the finding', () => {
it('flags the declaration #7521 was filed for', () => {
const findings = validateManagedApiMethods({ objects: [sysEnvironment] });
expect(findings).toHaveLength(1);
const [f] = findings;
expect(f.severity).toBe('error');
expect(f.rule).toBe(MANAGED_API_METHOD_UNAFFORDABLE);
expect(f.where).toBe('object "sys_environment"');
expect(f.path).toBe('objects[0].enable.apiMethods');
expect(f.message).toContain('create, update');
expect(f.message).toContain("managedBy: 'platform'");
});

it('offers both ways out, and names the affordances that would be needed', () => {
const [f] = validateManagedApiMethods({ objects: [sysEnvironment] });
expect(f.hint).toContain('userActions: { create: true, edit: true }');
expect(f.hint).toContain('remove');
// ADR-0092 D4 — an author must not open the affordance to silence a lint.
expect(f.hint).toContain('ADR-0092 D4');
});

it('emits ONE finding per object, not one per offending verb', () => {
// Three refused verbs, one authoring mistake, one edit to fix it.
const findings = validateManagedApiMethods({
objects: [
{
name: 'sys_thing',
managedBy: 'better-auth',
enable: { apiMethods: ['get', 'create', 'update', 'delete'] },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('create, update, delete');
});
});

describe('validateManagedApiMethods — the walk', () => {
it('reads objects declared as a name-keyed map as well as an array', () => {
const findings = validateManagedApiMethods({ objects: { sys_environment: sysEnvironment } });
expect(findings).toHaveLength(1);
expect(findings[0].where).toBe('object "sys_environment"');
});

it('reports the index of the offending object, not of the finding', () => {
const clean = { name: 'crm_lead', enable: { apiMethods: ['get', 'create'] } };
const findings = validateManagedApiMethods({ objects: [clean, clean, sysEnvironment] });
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('objects[2].enable.apiMethods');
});

it('stays silent on a coherent stack', () => {
const findings = validateManagedApiMethods({
objects: [
// Unmanaged — no bucket default to contradict.
{ name: 'crm_lead', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete'] } },
// Managed, and the affordance is declared (the `sys_api_key` shape).
{
name: 'sys_api_key',
managedBy: 'better-auth',
userActions: { edit: true },
enable: { apiMethods: ['get', 'list', 'update'] },
},
// Managed and read-only — reads are never affordance-gated.
{ name: 'sys_email', managedBy: 'append-only', enable: { apiMethods: ['get', 'list'] } },
],
});
expect(findings).toEqual([]);
});

it('survives a stack that is missing, junk, or has no objects at all', () => {
expect(validateManagedApiMethods(undefined)).toEqual([]);
expect(validateManagedApiMethods('nope')).toEqual([]);
expect(validateManagedApiMethods({})).toEqual([]);
expect(validateManagedApiMethods({ objects: [null, 42] })).toEqual([]);
});
});
121 changes: 121 additions & 0 deletions packages/lint/src/validate-managed-api-methods.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#7521, found via cloud#1225] The authoring-time half of
// `reconcileManagedApiMethods` — a managed object may not advertise a generic
// write verb in `enable.apiMethods` that its own resolved affordances refuse.
//
// A pure `(stack) => Finding[]` rule (ADR-0019). All judgement lives in the
// SHARED predicate — `@objectstack/spec/data`'s
// `checkManagedApiMethodAffordances`, the same call objectql's registry makes
// when it strips the verb at registration — so this file is only the walk:
// where objects live in a stack, and how a predicate conflict becomes a lint
// finding with a location. If a verdict seems wrong, fix the predicate, never
// this walk. A second affordance table here would BE the declared≠enforced
// drift the rule exists to catch.
//
// ## Why an authoring-time rule when the registry already fixes it
//
// The registry's fix is real and fail-closed — it strips the verb, so nothing
// is ever exposed. What it cannot do is TELL anyone. `sys_environment` and
// `sys_package` declared `apiMethods: ['get','list','create','update']` against
// `userActions` that refused all three writes; the strip and its `console.warn`
// fired on every control-plane boot for the life of the divergence and nobody
// noticed. The split was found by hand-driving the HTTP seam while writing
// something else. A boot log is not an authoring surface: it is read after an
// incident, by an operator, in a repo whose author has long since moved on.
//
// This rule puts the same verdict where the author is standing, which is the
// #7521 ruling in one sentence. Boot behaviour is deliberately unchanged —
// still warn-and-strip, never fail-closed, so a metadata typo cannot kill a
// control-plane boot.
//
// Runs on the NORMALIZED (pre-parse) stack, like validate-functional-
// completeness: the finding must reach the author even when an unrelated schema
// error would stop the parse, and the predicate reads only authored keys
// (`managedBy`, `userActions`, `enable.apiMethods`) — no parse-time defaults.

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

/**
* Stable diagnostic id. Named for the CONTRADICTION rather than for the strip,
* because at authoring time nothing has been stripped yet — the declaration is
* simply advertising something the object cannot honour.
*/
export const MANAGED_API_METHOD_UNAFFORDABLE = 'object/managed-api-method-unaffordable';

export interface ManagedApiMethodFinding {
/**
* Always `error`. The contradiction is decidable from the object's own
* declaration — no call graph, no runtime state — and shipping it means the
* metadata claims an API surface the registry will silently take away.
*/
severity: 'error';
rule: typeof MANAGED_API_METHOD_UNAFFORDABLE;
/** Human-readable location, e.g. `object "sys_environment"`. */
where: string;
/** Config path, e.g. `objects[2].enable.apiMethods`. */
path: string;
message: string;
hint: string;
}

type AnyRec = Record<string, unknown>;

const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);

/** Array-or-name-keyed-map collection → entries with a name and an index label. */
function entriesOf(v: unknown): Array<{ name: string; def: AnyRec; key: string }> {
if (Array.isArray(v)) {
return v.flatMap((def, i) =>
isRec(def) ? [{ name: String(def.name ?? i), def, key: `[${i}]` }] : [],
);
}
if (isRec(v)) {
return Object.entries(v).flatMap(([name, def]) =>
isRec(def) ? [{ name, def: { name, ...def }, key: `.${name}` }] : [],
);
}
return [];
}

/**
* Walk every object declaration in the stack through the shared
* managed-affordance predicate.
*
* One finding per object, not per verb: an object declaring both `create` and
* `update` against an all-locked bucket has ONE authoring mistake, and the fix
* — open the affordances or drop the verbs — is a single edit.
*/
export function validateManagedApiMethods(stack: unknown): ManagedApiMethodFinding[] {
const out: ManagedApiMethodFinding[] = [];
if (!isRec(stack)) return out;

for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const conflicts = checkManagedApiMethodAffordances(obj.def);
if (conflicts.length === 0) continue;

const verbs = conflicts.map((c) => c.verb).join(', ');
const flags = [...new Set(conflicts.map((c) => c.needs))];
out.push({
severity: 'error',
rule: MANAGED_API_METHOD_UNAFFORDABLE,
where: `object "${obj.name}"`,
path: `objects[${oi}].enable.apiMethods`,
message:
`\`managedBy: '${String(obj.def.managedBy)}'\` object "${obj.name}" ` +
describeManagedApiMethodConflicts(conflicts) +
` The registry STRIPS [${verbs}] at registration, so this declaration and the API you ` +
`actually get already disagree — today the only trace is a line in the boot log.`,
hint:
`Either add \`userActions: { ${flags.map((f) => `${f}: true`).join(', ')} }\` to the object ` +
`— only if the write is genuinely one a user context may perform, and only once the guard ` +
`enforcing it exists (ADR-0092 D4: affordance never ships ahead of the guard) — or remove ` +
`[${verbs}] from \`enable.apiMethods\`, which is what the runtime does for you today.`,
});
}

return out;
}
Loading
Loading