Skip to content

Commit 311b4ee

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17176-oauth-resource-seed
2 parents a227a9c + 92865f6 commit 311b4ee

8 files changed

Lines changed: 1188 additions & 21 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322)
6+
7+
<!-- adr-0087: not-required (already-registered flow-edge-condition-evaluated-slot-source-required) this is a second face of the decision that entry already carries — an evaluated slot requires a non-blank `source`, refused with EVALUATED_EXPRESSION_SOURCE_REQUIRED — applied to the other structural condition slot by importing the same schema rather than by deriving a second rule; no key is renamed, retired or given a new meaning here. ⚠️ That entry's `surface` and `acceptanceCriteria` name only `edges[].condition`, so they need widening to `config.condition` for a consumer replaying the chain; that file is in packages/spec, outside this card's package, and is filed as a follow-up rather than edited here. -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
10+
(the lockstep convention: `major` is refused by `check-changeset-no-major`, and
11+
breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow
12+
node's `config.condition` — a `decision` node's predicate, and on a `start` node
13+
the **trigger gate** — is now refused at `registerFlow` when its source is blank
14+
after trimming, where it used to register clean and answer a **silent `false`**
15+
at every evaluation.
16+
17+
Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition`
18+
composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is
19+
refused at `FlowSchema.parse`, by name. A node's `config` is an open
20+
`z.record(z.string(), z.unknown())`, so the same value passed through verbatim,
21+
reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim()
22+
=== ''` — and returned `false`, under a comment that names that arm as being for
23+
an **unauthored** condition. `' '` was authored. The branch never ran, forever,
24+
with nothing said at any layer.
25+
26+
```yaml
27+
nodes:
28+
- { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut
29+
- { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key
30+
```
31+
32+
> An expression in an evaluated slot needs a non-blank `source`: the expression
33+
> engine evaluates `source` (the canonical persisted form of phase M9.1) and
34+
> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source`
35+
> that is blank after trimming, would validate and register and then fault at
36+
> run time. Write `{ dialect: 'cel', source: '…' }`.
37+
38+
- **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs
39+
the condition's source through `EvaluatedExpressionInputSchema` itself, so the
40+
node door and the edge door cannot drift into two notions of "blank" or two
41+
sentences for it — the property the #15662 campaign built the shared refusal
42+
for. Nothing is exported from this package to carry it, and no new export was
43+
added.
44+
- **Applied to the SOURCE, not to the whole value**, deliberately: the union
45+
would also refuse an envelope with no `dialect` or with a dialect outside its
46+
enum, and this slot admits both (`structuralConditionRefusal`'s docblock,
47+
#4336). The narrowing is exactly the blank population and nothing else — a
48+
`cron` envelope with a real source still earns its own pre-existing verdict,
49+
and a bare string with a `{…}` brace trap still earns #1491's.
50+
- **`evaluateCondition` is unchanged and still answers `false`.** It is the
51+
shared evaluator and a public method on an exported class, so its throw
52+
behaviour is itself a contract; and a stored flow reaches it whatever the
53+
producer refuses. This change is at the producer only.
54+
- **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped
55+
condition; the new refusal sits behind the shape one and in front of the CEL
56+
one, and answers the evaluated-slot sentence rather than
57+
`STRUCTURAL_CONDITION_SHAPE_REFUSAL`.
58+
59+
**What an author does with a refused condition.** A whitespace-only condition was
60+
never a predicate — the engine answered `false`, so the branch never fired, and on
61+
a `start` node the flow never triggered. **Remove the `condition` key** if the node
62+
was meant to be unconditional, or **write the expression** if it was meant to
63+
branch. ⚠️ Those two are not interchangeable: a refused condition never fired,
64+
while an absent `condition` on a decision node is an unconditional branch that
65+
always fires and an absent one on a start node is a gate that always opens.
66+
Deleting the key to clear the refusal inverts the node rather than preserving it.
67+
Every condition with a non-blank source is unchanged, and nothing is renamed or
68+
retired.
69+
70+
**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow,
71+
not just the branch.** Stored flows are deliberately not canonicalized by
72+
`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same
73+
skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize
74+
at `registerFlow`, and each of the three boot paths in
75+
`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one
76+
`warn` naming the flow, and continues. So a node condition that used to answer a
77+
silent `false` while the rest of the flow ran now takes the flow down with it: it
78+
is never registered, its trigger is never armed, and the announcement is that one
79+
warn line — `[Automation] failed to register flow` at boot, `[Automation]
80+
cold-boot flow bind: failed to register flow` at the kernel:ready bind,
81+
`[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line
82+
is also the locator: the refusal names the node and the slot, e.g. `node 'gate'
83+
(start) condition`. A stack authored in config files has a second door,
84+
`objectstack validate` — see the note below for what that door does **not** yet
85+
say.
86+
87+
**A repo-wide census on this branch found zero authored `config.condition` values
88+
of this shape**, against a lit control: a textual probe over all 8,123 tracked
89+
source files found **461** non-blank `condition:` string literals and **zero**
90+
blank-after-trim ones in any authored flow (the four blank hits are two prose
91+
examples inside #15807's own changeset and two `packages/lint` test fixtures).
92+
There is nothing in this repository to rewrite.
93+
94+
⚠️ **Two follow-ups this change does not carry, both outside this card's package.**
95+
(1) The ADR-0087 D3 entry named above,
96+
`flow-edge-condition-evaluated-slot-source-required`, registers the decision this
97+
change is a second face of — an evaluated slot requires a non-blank `source` — but
98+
its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need
99+
widening to `config.condition` so a consumer replaying the chain is told to sweep
100+
the node key too; that file is in `packages/spec`.
101+
(2) `@objectstack/lint`'s `validate-expressions` applies only
102+
`structuralConditionRefusal` to a structural condition, so `objectstack validate`
103+
still reports nothing for a blank `config.condition` that `registerFlow` now
104+
refuses — the two doors disagree until that rule is rebound as well.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422)
6+
7+
`admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound.
8+
9+
The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0.
10+
11+
`FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes.
12+
13+
No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached.

packages/plugins/plugin-auth/src/admin-import-users.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi } from 'vitest';
4+
import { readFileSync } from 'node:fs';
5+
import { dirname, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
47
import { assertEngineUpdateDispatch } from '@objectstack/objectql';
58
import { runAdminImportUsers, IMPORT_USERS_MAX_ROWS, type IdentityImportDeps } from './admin-import-users.js';
69
import type { AdminActor } from './admin-user-endpoints.js';
710

811
const ACTOR: AdminActor = { id: 'admin-1', email: 'admin@example.com' };
912

13+
const HERE = dirname(fileURLToPath(import.meta.url));
14+
const IMPORT_USERS_SOURCE = readFileSync(resolve(HERE, 'admin-import-users.ts'), 'utf8');
15+
1016
function makeRequest(body: unknown): Request {
1117
return new Request('http://localhost/api/v1/auth/admin/import-users', {
1218
method: 'POST',
@@ -624,3 +630,33 @@ describe('runAdminImportUsers — CSV payloads', () => {
624630
expect(m.createUser.mock.calls.map((c) => c[0].body.email).sort()).toEqual(['c1@x.co', 'c2@x.co']);
625631
});
626632
});
633+
634+
/**
635+
* [#17422] The IMPLEMENTOR half of #16952's contract.
636+
*
637+
* `ImportProtocolLike` types the three required members, but an EXPLICIT
638+
* parameter annotation wins over a contextual type — so `findData(args: any)`
639+
* opts this file back out of the contract while `tsc --noEmit` stays green.
640+
* Measured on #17422 in this package: with the annotation restored, a probe
641+
* reading the retired wire alias (`args.query?.$filter` — the pre-#16950 read
642+
* whose `?? {}` default degraded the duplicate probe into match-everything)
643+
* type-checks at exit 0; with the annotation gone the same probe is
644+
* `TS2339 Property '$filter' does not exist on type 'QueryInput'`.
645+
*
646+
* ⇒ Nothing else in the repo can see that difference. The behavioural upsert
647+
* tests above discriminate the CONSEQUENCE (ablated to the historical read,
648+
* two of them go red) but not the opt-out itself: re-annotating the parameter
649+
* leaves every one of them green and every gate green. This is that guard.
650+
*/
651+
describe('[#17422] the import protocol literal is typed BY `ImportProtocolLike`', () => {
652+
it('binds the literal to the exported contract', () => {
653+
expect(IMPORT_USERS_SOURCE).toContain('const protocol: ImportProtocolLike = {');
654+
});
655+
656+
for (const member of ['findData', 'createData', 'updateData'] as const) {
657+
it(`leaves \`${member}\`'s parameter unannotated, so the contract types it`, () => {
658+
expect(IMPORT_USERS_SOURCE).toContain(`async ${member}(args) {`);
659+
expect(IMPORT_USERS_SOURCE).not.toMatch(new RegExp(`async\\s+${member}\\s*\\(\\s*args\\s*:`));
660+
});
661+
}
662+
});

packages/plugins/plugin-auth/src/admin-import-users.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -362,17 +362,34 @@ export async function runAdminImportUsers(
362362
// to `{}` stops constraining anything, so the duplicate probe matches rows
363363
// it was given no key for and the upsert updates the WRONG user. One
364364
// dialect, read straight — a request that arrives without a `query` is a
365-
// caller defect and costs a loud TypeError, not a silent match-everything.
366-
async findData(args: any) {
367-
const where = args.query.where;
368-
const limit = args.query.limit;
365+
// caller defect and is refused loudly, not softened into match-everything.
366+
//
367+
// [#17422] The parameter is deliberately UNANNOTATED: `ImportProtocolLike`
368+
// types it, and an explicit annotation here would win over that contextual
369+
// type and opt this implementor back out of the contract (the runner's own
370+
// docblock says so). `FindDataRequest` declares `query` OPTIONAL, so the
371+
// contract makes this file write its refusal down instead of leaving it as
372+
// an incidental TypeError from a property read on `undefined`.
373+
// ⛔ Not `args.query ?? {}` and ⛔ not `args.query?.where`: both spell
374+
// match-everything, which is the exact regression this protocol's history
375+
// is about.
376+
async findData(args) {
377+
const query = args.query;
378+
if (!query) {
379+
throw Object.assign(
380+
new Error('import-users: findData was called without a query — refusing to match every user'),
381+
{ code: 'INVALID_REQUEST' },
382+
);
383+
}
384+
const where = query.where;
385+
const limit = query.limit;
369386
return engine.find(args.object, { where, limit, context: SYSTEM_CTX } as any);
370387
},
371388

372389
// One better-auth create per row — hashing + credential sys_account.
373390
// Deliberately NO createManyData: there is no safe bulk primitive for
374391
// identities, and scrypt dominates the cost anyway.
375-
async createData(args: any) {
392+
async createData(args) {
376393
const data: Record<string, any> = args?.data ?? {};
377394
const email: string = typeof data.email === 'string' && data.email.length > 0
378395
? data.email
@@ -431,7 +448,7 @@ export async function runAdminImportUsers(
431448

432449
// Upsert updates touch PROFILE fields only — never email, never anything
433450
// credential- or system-managed. An empty filtered patch is a no-op.
434-
async updateData(args: any) {
451+
async updateData(args) {
435452
const patch: Record<string, any> = {};
436453
for (const [k, v] of Object.entries(args?.data ?? {})) {
437454
if (UPDATE_ALLOWED_FIELDS.has(k) && v !== undefined && v !== null && v !== '') patch[k] = v;

packages/services/service-automation/src/engine.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRe
3535
// ({@link AutomationEngine.valueEnvelopeRefusals}), so a flow that registers can
3636
// never be refused at run time and vice versa.
3737
import { AssignmentValueSchema, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation';
38+
// [#17322] The EVALUATED-slot rule, IMPORTED rather than re-derived. It is the
39+
// rule `FlowEdgeSchema.condition` already composes since #15807, so a node's
40+
// `config.condition` — which no schema stands in front of — is held to the same
41+
// notion of "blank" and answers the same published sentence. A second,
42+
// hand-written `trim()` here is exactly the drift the #15662 campaign built the
43+
// shared refusal to prevent. See `checkStructuralCondition` in `registerFlow`.
44+
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec';
3845
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
3946
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
4047
// Routed through the local precedence module rather than importing
@@ -8060,14 +8067,47 @@ export class AutomationEngine implements IAutomationService {
80608067
* node's trigger gate is read from. Same severity as a malformed
80618068
* predicate (this throws): the reject set of registration and the reject
80628069
* set of evaluation must be one set.
8070+
*
8071+
* [#17322] SECOND gate, after the shape one and before the CEL one: the
8072+
* source must be non-blank. `structuralConditionRefusal` admits every
8073+
* string by design, so a whitespace-only `config.condition` passed here
8074+
* and landed on `evaluateCondition`'s empty-source arm — a SILENT
8075+
* `false`, i.e. a branch that never runs, forever, under a comment that
8076+
* names that arm as being for an UNAUTHORED condition. `' '` was
8077+
* authored, and on a `start` node that key is the trigger gate. Since
8078+
* #15807 the EDGE door refuses exactly this value at
8079+
* `FlowSchema.parse`, and 带治理的一侧胜出,另一侧改绑: the node door,
8080+
* which has no schema in front of it, aligns to the governed side HERE,
8081+
* at the producer.
80638082
*/
8083+
const evaluatedSourceRefusal = (raw: unknown): { message: string; source: string } | undefined => {
8084+
// Reached only after `structuralConditionRefusal` cleared the value,
8085+
// so `raw` is bare text or an envelope carrying a string `source`.
8086+
const source = typeof raw === 'string' ? raw : (raw as { source?: unknown }).source;
8087+
if (typeof source !== 'string') return undefined;
8088+
// The rule and its sentence both come from the edge door's own
8089+
// schema. Applied to the SOURCE rather than to the whole value on
8090+
// purpose: the union would also refuse an envelope with no
8091+
// `dialect` or a dialect outside its enum, both of which this slot
8092+
// admits (`structuralConditionRefusal`'s docblock, and #4336) — and
8093+
// refusing them would widen this narrowing past what was ruled.
8094+
const verdict = EvaluatedExpressionInputSchema.safeParse(source);
8095+
if (verdict.success) return undefined;
8096+
return { message: verdict.error.issues[0]?.message ?? EVALUATED_EXPRESSION_SOURCE_REQUIRED, source };
8097+
};
8098+
80648099
const checkStructuralCondition = (where: string, raw: unknown): void => {
80658100
if (raw == null) return;
80668101
const shapeRefusal = structuralConditionRefusal(raw);
80678102
if (shapeRefusal) {
80688103
failures.push(` • ${where}: ${shapeRefusal.message}\n source: \`${shapeRefusal.source}\``);
80698104
return;
80708105
}
8106+
const blankRefusal = evaluatedSourceRefusal(raw);
8107+
if (blankRefusal) {
8108+
failures.push(` • ${where}: ${blankRefusal.message}\n source: \`${blankRefusal.source}\``);
8109+
return;
8110+
}
80718111
check(where, raw);
80728112
};
80738113

0 commit comments

Comments
 (0)