Skip to content

Commit 86f4246

Browse files
claude[bot]claude
andauthored
fix(lint): flag an approval slate that is entirely manager rungs (#17034)
* fix(lint): flag an approval slate that is entirely manager rungs `approval-approvers-may-resolve-empty` reasoned only about group-routed rungs (position/team/department) and was silent on `{ type: 'manager' }`, which has the same empty-slate failure shape and a worse cause: an unstaffed position is an operator's to fix in-product, an unset `sys_user.manager_id` is not — the managed-update whitelist is {name, image, locale}, the auth admin endpoints refuse the column and the Console renders no field for it. Adds a second arm under the same rule id and the same `info` tier. It is scoped to slates that are ENTIRELY manager rungs, which keeps it disjoint from the existing arm by construction and leaves every `position` verdict — mixed slates included — byte-identical. The message says plainly that the check is static and does not assert the slate is empty; the hint prescribes SCIM / import / directory sync rather than a Console edit that is not possible. A stack whose own seeds wire `sys_user.manager_id` silences it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU * fix(lint): keep tracker ids out of the manager rung's runtime prose `check:doc-authoring` Rule 3: a runtime string reaches authors, operators and generated surfaces, none of whom can resolve a bare id. The anchors move to the adjacent comments, where the reader who can resolve them is already reading the source. Also adds the changeset — the new advisory prose reaches four published bundles (dist/index.{js,cjs}, dist/runtime.{js,cjs}), all inside the package's `files`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU * fix(lint): grade the approvers widening at minor, not patch A rule that begins covering a case it was silent on is a purely additive widening of a published package's public surface, and the standing maintainer ruling puts a floor of `minor` on that act regardless of the commit type. The clause-② declaration this PR carries is correct and stays; it was the level under it that was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU * fix(lint): grade the manager-rung remedy routes, and say where the seed suppressor applies The hint listed SCIM, bulk import and directory sync as equally available routes for populating `sys_user.manager_id`. Measured against this tree, two of the three have no writer here: `admin-import-users.ts` matches `manager_id` 0 times against a control of `phone_number` 8 and its update set is {name, image, locale} + phone_number + role, and the SCIM Enterprise `manager` attribute is declared without any non-test file projecting it onto the column. A seed — or any other system-context write — does work, because both write guards gate on `isUserContextWrite` (`userId && !isSystem`). An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the routes are now graded rather than listed. SCIM and directory sync stay named: a deployment running a real one may populate the column through it, and the defect was presenting them as something this platform provides. Also records the surface asymmetry of the seed suppressor. The runtime publish gate's context carries objects, permissions, books, datasets and pages, and no `data`, so the suppression is CLI-side only and a Studio publish draws the advisory however the tenant's users are wired. Prose only — the arm, the tier and the position arm are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2cd4c54 commit 86f4246

3 files changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
`approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones.
6+
7+
The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one.
8+
9+
- **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked.
10+
- **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read.
11+
- **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`.
12+
- **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule.
13+
14+
Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to.
15+
16+
This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type.
17+
18+
No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict.

‎packages/lint/src/validate-approval-approvers.test.ts‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,187 @@ describe('validateApprovalApprovers', () => {
220220
});
221221
});
222222

223+
// ── unset-manager dead-end (#16748) ──────────────────────────────────────
224+
//
225+
// The #3424 arm above reasons about `position`/`team`/`department` and was
226+
// silent on `{ type: 'manager' }` — measured on the parent commit as
227+
// `git grep -c "'manager'"` = 0 against `'position'` = 4 in the rule file, and
228+
// confirmed behaviourally: a manager-only node returned `[]`.
229+
//
230+
// Every count here carries its controls, because an implementation that simply
231+
// always fires satisfies the positive case on its own and is indistinguishable
232+
// without them.
233+
234+
describe('unset-manager dead-end (#16748)', () => {
235+
const managerOnly = () => stackWithApprovers([{ type: 'manager' }]);
236+
237+
/** A stack that demonstrably wires `sys_user.manager_id` in its own seeds. */
238+
const withSeededManagerChain = (stack: Record<string, unknown>) => {
239+
stack.data = [{
240+
object: 'sys_user',
241+
mode: 'upsert',
242+
externalId: 'name',
243+
records: [
244+
{ name: 'ceo' }, // top of the chain — no manager, correctly
245+
{ name: 'ic', manager_id: 'ceo' },
246+
],
247+
}];
248+
return stack;
249+
};
250+
251+
it('FIRES on a node whose whole slate is { type: manager }, at info', () => {
252+
const findings = validateApprovalApprovers(managerOnly());
253+
expect(findings).toHaveLength(1);
254+
expect(findings[0].rule).toBe(APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY);
255+
// ⛔ Tier boundary: the same advisory tier as its `position` sibling. An
256+
// `error` here would red every stack that authors a manager rung today.
257+
expect(findings[0].severity).toBe('info');
258+
expect(findings[0].path).toBe('flows[0].nodes[1].config.approvers');
259+
expect(findings[0].message).toContain('locked'); // lockRecord defaults true
260+
});
261+
262+
it('names the REAL remedy — provisioning, not the Console', () => {
263+
const [finding] = validateApprovalApprovers(managerOnly());
264+
// The prescription an operator can actually carry out (#16678: the column
265+
// has no product write surface).
266+
expect(finding.hint).toContain('SCIM');
267+
expect(finding.hint).toContain('import');
268+
expect(finding.hint).toContain('directory sync');
269+
expect(finding.hint).toContain('no product write surface');
270+
// ⛔ And it must not send them to a surface that cannot write it. The word
271+
// "Console" appears only inside that denial, never as an instruction.
272+
expect(finding.hint).toContain('never populated by editing the user in the Console');
273+
expect(finding.hint).not.toMatch(/[Ee]dit .{0,40}in the Console\b(?!.*NOT)/);
274+
// It still offers the escape that does not depend on #16678 at all.
275+
expect(finding.hint).toContain("org_membership_level', value: 'owner'");
276+
});
277+
278+
it('GRADES the routes — an exact diagnosis whose remedy cannot be carried out is worse than none', () => {
279+
// A remedy that names a route with no writer is the #17037 shape. The three
280+
// routes are measured against this tree, so the hint must SEPARATE the one
281+
// that works here from the ones that need the deployment's own provisioning.
282+
const [finding] = validateApprovalApprovers(managerOnly());
283+
284+
// The route with a demonstrated writer: a system-context write bypasses the
285+
// managed-update whitelist (`isUserContextWrite` is `userId && !isSystem`).
286+
expect(finding.hint).toContain('written by a seed, or by any other system-context write');
287+
expect(finding.hint).toContain('bypasses the managed-update whitelist');
288+
289+
// ⛔ The two that are NOT this repo's to offer must be marked as the
290+
// deployment's own, and the hint must say WHY rather than merely hedging.
291+
expect(finding.hint).toContain('a provisioning path your own deployment supplies');
292+
expect(finding.hint).toContain("declares the SCIM 'manager' attribute without projecting it");
293+
expect(finding.hint).toContain('admin bulk import does not write it either');
294+
295+
// ⛔ And they must not be deleted: a deployment running a real directory
296+
// sync may well populate the column, and the defect was presenting all
297+
// three as equally available, never naming them at all.
298+
expect(finding.hint).toContain('SCIM provisioning and directory sync can populate it');
299+
});
300+
301+
it('does not claim a runtime fact it did not read', () => {
302+
const [finding] = validateApprovalApprovers(managerOnly());
303+
expect(finding.message).toContain('a static check cannot read that column');
304+
expect(finding.message).toContain('does not assert the slate IS empty');
305+
});
306+
307+
// ── negative controls ──────────────────────────────────────────────────
308+
309+
it('NEGATIVE: a populated manager chain in the stack emits nothing', () => {
310+
expect(validateApprovalApprovers(withSeededManagerChain(managerOnly()))).toEqual([]);
311+
});
312+
313+
it('NEGATIVE: a stack authoring neither rung emits nothing', () => {
314+
expect(validateApprovalApprovers(stackWithApprovers([{ type: 'user', value: 'u1' }]))).toEqual([]);
315+
expect(validateApprovalApprovers({ flows: [] })).toEqual([]);
316+
expect(validateApprovalApprovers({})).toEqual([]);
317+
});
318+
319+
it('NEGATIVE: a fallback that cannot resolve empty silences it', () => {
320+
expect(validateApprovalApprovers(stackWithApprovers([
321+
{ type: 'manager' },
322+
{ type: 'org_membership_level', value: 'owner' },
323+
]))).toEqual([]);
324+
expect(validateApprovalApprovers(stackWithApprovers([
325+
{ type: 'manager' },
326+
{ type: 'user', value: 'u1' },
327+
]))).toEqual([]);
328+
});
329+
330+
// ── the suppressor's own controls ──────────────────────────────────────
331+
332+
it('the seed suppressor discriminates: it reads sys_user.manager_id and only that', () => {
333+
// FIRING control — sys_user rows that carry no manager_id suppress nothing.
334+
const noChain = managerOnly();
335+
noChain.data = [{ object: 'sys_user', mode: 'upsert', records: [{ name: 'ic' }] }];
336+
expect(validateApprovalApprovers(noChain)).toHaveLength(1);
337+
338+
// NONSENSE control — the same column on some OTHER object is not evidence
339+
// about `sys_user`, and an empty / malformed `data` is not evidence either.
340+
const wrongObject = managerOnly();
341+
wrongObject.data = [{ object: 'sys_team', mode: 'upsert', records: [{ name: 't', manager_id: 'ceo' }] }];
342+
expect(validateApprovalApprovers(wrongObject)).toHaveLength(1);
343+
344+
const junk = managerOnly();
345+
junk.data = ['garbage', null, { object: 'sys_user' }, { object: 'sys_user', records: 'oops' }];
346+
expect(validateApprovalApprovers(junk)).toHaveLength(1);
347+
348+
// And a blank string is not a populated chain.
349+
const blank = managerOnly();
350+
blank.data = [{ object: 'sys_user', records: [{ name: 'ic', manager_id: ' ' }] }];
351+
expect(validateApprovalApprovers(blank)).toHaveLength(1);
352+
});
353+
354+
// ── regression controls: the `position` arm is untouched ───────────────
355+
356+
it('REGRESSION: the position arm keeps its own verdict and its own message', () => {
357+
const positionOnly = validateApprovalApprovers(stackWithApprovers([
358+
{ type: 'position', value: 'exec' },
359+
]));
360+
expect(positionOnly).toHaveLength(1);
361+
expect(positionOnly[0].message).toContain('routes to a group (position/team/department)');
362+
expect(positionOnly[0].message).not.toContain('manager_id');
363+
364+
// The mixed slate this package has always pinned as silent stays silent —
365+
// this arm is scoped to slates that are ENTIRELY manager rungs.
366+
expect(validateApprovalApprovers(stackWithApprovers([
367+
{ type: 'position', value: 'exec' },
368+
{ type: 'manager' },
369+
]))).toEqual([]);
370+
});
371+
372+
it('the two arms are disjoint — no node ever draws both findings', () => {
373+
for (const approvers of [
374+
[{ type: 'manager' }],
375+
[{ type: 'manager' }, { type: 'manager', value: 'requested_by' }],
376+
[{ type: 'position', value: 'exec' }],
377+
[{ type: 'position', value: 'exec' }, { type: 'team', value: 't1' }],
378+
[{ type: 'position', value: 'exec' }, { type: 'manager' }],
379+
]) {
380+
const hits = validateApprovalApprovers(stackWithApprovers(approvers))
381+
.filter((f) => f.rule === APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY);
382+
expect(hits.length).toBeLessThanOrEqual(1);
383+
}
384+
});
385+
386+
it('a multi-rung manager ladder is one finding, not one per rung', () => {
387+
const findings = validateApprovalApprovers(stackWithApprovers([
388+
{ type: 'manager' },
389+
{ type: 'manager', value: 'requested_by' },
390+
]));
391+
expect(findings).toHaveLength(1);
392+
expect(findings[0].rule).toBe(APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY);
393+
});
394+
395+
it('drops the record-lock clause when lockRecord is false', () => {
396+
const stack = managerOnly();
397+
(stack.flows as any)[0].nodes[1].config.lockRecord = false;
398+
const findings = validateApprovalApprovers(stack);
399+
expect(findings).toHaveLength(1);
400+
expect(findings[0].message).not.toContain('locked');
401+
});
402+
});
403+
223404
// ── #3447 P2: expression approvers / decision outputs ─────────────────────
224405

225406
describe('expression approvers (#3447 P2)', () => {

0 commit comments

Comments
 (0)