Skip to content

Commit 33105e7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/pagination-tie-breaker-missing-p98qf0
2 parents 106a300 + 302e972 commit 33105e7

60 files changed

Lines changed: 1167 additions & 404 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
---
5+
6+
feat(approvals,spec): structured reassign hand-off parties on `sys_approval_action` (#4365)
7+
8+
A reassign's audit row used to encode "who handed the slot to whom" only inside
9+
a default free-text comment — `"<from_id> → <to_id>"`, two raw user ids — which
10+
clients could neither parse reliably nor render readably, so the approvals
11+
timeline showed opaque identifier soup for the single most important fact of
12+
the entry.
13+
14+
- `sys_approval_action` gains `reassign_from` / `reassign_to`
15+
(`lookup('sys_user')`), written by `ApprovalService.reassign()`.
16+
- `comment` is pure user input again: nothing is invented when the actor
17+
supplies none.
18+
- `listActions()` resolves both parties' display names into
19+
`reassign_from_name` / `reassign_to_name`, alongside the existing
20+
`actor_name`, so timelines can render "from A to B" without extra lookups.
21+
- `ApprovalActionRow` (spec contract) declares the four new fields.
22+
23+
Pre-existing rows keep their legacy comment; clients should prefer the
24+
structured fields when present and fall back to `comment` otherwise.

.changeset/console-7d9734d5e321.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `7d9734d5e321`. Frontend changes in this range:
6+
7+
- feat(core): say which column identity key won, out loud (#3104 PR3) (#3124)
8+
- fix(detail): Attachments become a peer tab with a live count badge, and their copy is translated (objectstack#4358) (#3123)
9+
- fix(console,app-shell): readable reassign hand-off + "System" label for svc:* audit actors (objectstack#4365, objectstack#4366) (#3121)
10+
- fix(fields): lookup multi-value hydration batches via $in and shows loading instead of the empty placeholder (#3108) (#3120)
11+
- fix(list,grid,detail,tree,core): every column resolver reads one key (#3104 PR2) (#3122)
12+
- fix(core,list): 列身份归一到 ingestion chokepoint — 一列一个身份 (#3104 PR1) (#3119)
13+
- fix(detail): a related list has one sorting semantics instead of two (#3106) (#3113)
14+
- feat(components,grid,list): a column-header sort orders the whole list, not the page you can see (#3106) (#3112)
15+
- fix(data-objectstack): a string `$orderby` reaches the server as a sort, not a list of character indices (#3106) (#3109)
16+
- fix(types,core): the `*Validation` five derive from spec 17, and the engine stops disagreeing with the server (#3103) (#3107)
17+
- fix(app-shell): lookup-param helpText only renders when the param actually degraded to a raw-id input (#3094) (#3095)
18+
- fix(form): numeric/boolean option values survive selection typed (#3090 PR3b) (#3100)
19+
- fix(list,detail): sorting a lookup column stops ordering by an invisible key (#3096) (#3102)
20+
- feat(flow-designer): the script node's form authors what the executor runs (framework#4278) (#3099)
21+
- fix(form): declare the runtime field metadata slot, ban the spec FormField misimport (#3090 PR3a) (#3097)
22+
- fix(console): LocalizationFetchProvider retries a transient /me/localization failure (#3098)
23+
- fix(app-shell,i18n): drop the developer-voiced default form subtitle (#3093)
24+
- fix(form): spec-vocabulary fields stop crashing the standalone form; every surface names the boundary (#3090) (#3092)
25+
- fix(form): harden the spec↔runtime form-field chokepoint, derive SelectOption, complete FormFieldSchema (#3090) (#3091)
26+
- fix(types,layout): navigation metadata stops losing the spec fields the renderer already honours (objectstack#4115) (#3088)
27+
28+
objectui range: `bebaebd39ace...7d9734d5e321`
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-automation": patch
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(automation,objectql,spec): attribute `runAs:'system'` flow writes to the flow in the audit log (#4366)
8+
9+
A `runAs:'system'` flow's data writes carried no attribution at all: the run
10+
context resolved to `{ isSystem: true }` with no `userId` and no service
11+
principal, so the audit writer recorded `user_id=null, actor=null` and the
12+
record-history UI rendered every such row as "Unknown user" — business users
13+
read the flow's own status mirror as data corruption.
14+
15+
The `svc:*` attribution channel (ADR-0014 D2, `ExecutionContext.actor`) already
16+
existed for exactly this class of writer; it was simply never wired end-to-end:
17+
18+
- **service-automation**`resolveRunContext` now stamps `flowName` alongside
19+
`runAs`/`flowRunId`, and `resolveRunDataContext` labels a `runAs:'system'`
20+
run's data context `actor: 'svc:flow:<flowName>'` (fallback
21+
`svc:flow:automation`). Attribution only — no security middleware keys on it.
22+
- **objectql**`buildSession` propagates `ExecutionContext.actor` onto the
23+
hook session, closing the gap that left the audit writer's
24+
`userId ?? session.actor` fallback unreachable from the engine path.
25+
- **spec**`AutomationContext.flowName` (engine-stamped, provenance) and the
26+
hook session's optional `actor` field document the contract.
27+
28+
No behavior change for user-attributed writes: `userId` still wins wherever it
29+
is present.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/core": patch
5+
"@objectstack/runtime": patch
6+
"@objectstack/metadata-protocol": patch
7+
"@objectstack/platform-objects": patch
8+
"@objectstack/plugin-auth": patch
9+
"@objectstack/plugin-hono-server": patch
10+
"@objectstack/plugin-security": patch
11+
---
12+
13+
feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3)
14+
15+
ObjectQL registers one instance under two names, and the ledger can finally say
16+
what each name means: `data` stays `IDataEngine` (the data plane), `objectql`
17+
now resolves to **`IObjectQLEngine`** — the full engine: schema access
18+
(`getSchema` / `getObject` / `registry`), actions (`registerAction` /
19+
`removeActionsByPackage` / `executeAction`), the hook/middleware seams
20+
(`registerHook` / `unregisterHooksByPackage` / `registerFunction` /
21+
`registerMiddleware` / `bindHooks`), the first-wins default runners and hook
22+
metrics, boot wiring (`registerDriver` / `setDatasourceMapping` /
23+
`registerApp`), and the ops probes (`checkDriversHealth` /
24+
`wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger
25+
test pins the new relation: `objectql` strictly widens `data`, deliberately no
26+
longer equal.
27+
28+
**Why now, and why `implements` is the point.** The honest state for two
29+
batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider
30+
than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine`
31+
would be "the more comfortable-looking lie". The interim discipline — each
32+
consumer declares the narrow slice it uses — produced seven local surfaces
33+
(`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`,
34+
`SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline
35+
`checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and
36+
each was an UNCHECKED claim: `getService<Surface>('objectql')` is an assertion,
37+
so an engine rename would have broken every consumer at runtime with zero
38+
compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into
39+
one compiler-verified claim. All seven stand-ins are deleted; consumers import
40+
the one declaration. `getObjectQL` is typed `Promise<IObjectQLEngine | null>`
41+
end to end, closing the oldest documented `any` in the dispatcher.
42+
43+
**Evidence bar unchanged.** Every declared member has a cross-package consumer
44+
reaching it through the slot; engine members without one (e.g. `triggerHooks`,
45+
cross-package only in tests) stay off until a caller appears. The registry view
46+
(`EngineSchemaRegistryView`) declares exactly the eight members consumers use.
47+
48+
**`_registry` never leaves the engine package now.** plugin-security's
49+
declared-metadata readers (`readDeclared`, permission-set projection, suggested
50+
audience bindings) reached ObjectQL's private `_registry` field through `any`
51+
the same private reach `/me/apps` had in B2, five more times. All migrated to
52+
the public `registry` getter the contract declares, test doubles included.
53+
54+
**`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by
55+
`MetadataManager` beside `watch` all along, reached through the slot only via
56+
`any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored
57+
hooks/actions live). With them declared, the bridge's six `metadata` lookups
58+
and metadata-protocol's `objectql` lookup carry contract types, and both files
59+
leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34
60+
files**.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
---
3+
4+
Test-only: a structural wiring guard so the drift #4394 just fixed cannot come
5+
back silently (#4384).
6+
7+
#4394 removed the *instance*`validateReadonlyFlowWrites` had been hand-wired
8+
into `os validate` and `os compile` and never into `os lint`, so an `error`-level
9+
gate left `os lint` passing stacks `os compile` refuses. It did not remove the
10+
*failure mode*: nothing stops the next rule from being wired into two commands
11+
out of three, and that defect produces no failing assertion anywhere — each
12+
command's tests pass, the rule's unit tests pass, and the only symptom is the
13+
three commands disagreeing about one stack. That is how the last one survived
14+
from #3425 until #4394.
15+
16+
`reference-integrity-wiring.test.ts` asserts each of the three commands calls
17+
`validateReferenceIntegrity`, and that none of them imports a suite member
18+
directly — the import being what a second call site starts with. It scans source
19+
rather than spying, for the reason `lazy-deps.test.ts` does: vitest inlines
20+
imports, so a module-cache probe cannot prove which symbols a command file
21+
actually reaches for. Verified to FAIL on a reintroduced direct import, not
22+
merely to pass today.
23+
24+
Releases nothing.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
One declaration of where ADR-0031 regions live (#4401).
7+
8+
A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the
9+
type system says which key on which node type holds one, so every pass that needs to reach
10+
a region node has to be told — and within one week three of them were told separately, by
11+
two changes that were each correct on their own:
12+
13+
| pass | package | table it carried |
14+
|------|---------|------------------|
15+
| `mapFlowNodes` (ADR-0087 conversions) | `spec` | `FLOW_REGION_SLOTS` |
16+
| `validateControlFlow` / `normalizeControlFlowRegions` / `collectFlowGraphs` | `spec` | `regionSlotsOf` |
17+
| `walkFlowNodes` (lint flow rules) | `lint` | `REGION_SLOTS` |
18+
19+
Each pinned its own copy with its own reconciliation test. So every copy was protected from
20+
drifting away from the schemas, and **nothing would have failed if the copies drifted from
21+
each other** — while adding a fourth construct meant editing three places, and missing one
22+
reproduces exactly the silent blind spot #4347 and #4380 were both filed about.
23+
24+
- New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (plus the
25+
`FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is now the only statement
26+
of the fact. It lives in an **import-free** module so `spec/conversions/walk.ts` can read
27+
it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema
28+
its value parses as stays in `control-flow.zod.ts`, which is schema business.
29+
- The three reconciliation tests collapse into one, `region-slots.test.ts`, keeping the
30+
strongest of them: it derives each construct's region keys **behaviourally**, by asking
31+
the config schema what it actually accepts in a region shape, rather than reading names
32+
off `.shape`. It also probes every other exported `*ConfigSchema`, so a new
33+
region-bearing construct cannot be added without either declaring its slots or failing
34+
here.
35+
36+
The three **walks** are deliberately left separate. They take different inputs (parsed
37+
`FlowNodeParsed` vs raw authored records), yield different units (a graph, a node, a
38+
copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node
39+
labels — consumer logic, not protocol (Prime Directive #2). Merging them would trade a
40+
duplicated four-line table for a walker that serves nobody well. Only the fact they all
41+
need is shared.
42+
43+
No behaviour change: every existing test passes unchanged, which is the point of the
44+
exercise.

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
bebaebd39ace05909f373f3c19014e5dc46b7f4f
1+
7d9734d5e3212c565d824c717354a4a2fddbfce2

content/docs/references/data/hook.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const result = HookContext.parse(data);
3737
| **input** | `Record<string, any>` || Mutable input parameters |
3838
| **result** | `any` | optional | Operation result (After hooks only) |
3939
| **previous** | `Record<string, any>` | optional | Record state before operation |
40-
| **session** | `{ userId?: string; organizationId?: string; roles?: string[]; accessToken?: string; … }` | optional | Current session context |
40+
| **session** | `{ userId?: string; actor?: string; organizationId?: string; roles?: string[]; … }` | optional | Current session context |
4141
| **provenance** | `{ flowRunId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
4242
| **transaction** | `any` | optional | Database transaction handle |
4343
| **ql** | `any` || ObjectQL Engine Reference |
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Structural wiring guard for the suite's whole reason to exist (#3583 §5 D5).
4+
//
5+
// ## Why a test and not a comment
6+
//
7+
// The defect this catches is not a wrong result — it is a MISSING CALL, and a
8+
// missing call produces no failing assertion anywhere. Every command's own
9+
// tests pass, the rule's unit tests pass, and the only symptom is that
10+
// `os validate`, `os lint` and `os compile` disagree about the same stack.
11+
//
12+
// That is not hypothetical. `validateReadonlyFlowWrites` was hand-wired into
13+
// `validate` and `compile` and never into `lint` from #3425 until #4394 — and
14+
// because it GATES (`flow-update-readonly-field` is an `error`), `os lint`
15+
// spent that whole time returning clean for stacks `os compile` refuses. The
16+
// suite's own header had been naming it as the standing proof of the drift the
17+
// suite exists to end, which is a comment doing a test's job. #4394 removed
18+
// that instance; this file removes the failure MODE, so the next rule cannot
19+
// repeat it silently (#4384).
20+
//
21+
// ## The invariant
22+
//
23+
// `os lint` ⊇ `os compile`'s gate set. `lint` is the cheap pre-flight and
24+
// `compile` is the gate; a green `lint` followed by a red `compile` makes the
25+
// pre-flight worthless — and for an agent it is worse than worthless, because
26+
// the remaining options are re-verifying everything (slow) or learning to
27+
// distrust the signal (dangerous).
28+
//
29+
// ## Why it scans source
30+
//
31+
// vitest inlines imports through its transform, so a spy on `@objectstack/lint`
32+
// cannot prove which symbols a command file actually reaches for — the same
33+
// reason `lazy-deps.test.ts` scans `src/` rather than probing a module cache.
34+
// Behavioural coverage of what the suite CONTAINS lives in
35+
// `@objectstack/lint`'s `reference-integrity-suite.test.ts`; this file guards
36+
// only the seam between that suite and the three call sites.
37+
38+
import { readFileSync } from 'node:fs';
39+
import { dirname, join } from 'node:path';
40+
import { fileURLToPath } from 'node:url';
41+
import { describe, it, expect } from 'vitest';
42+
import { REFERENCE_INTEGRITY_RULES } from '@objectstack/lint';
43+
44+
const commandsDir = dirname(fileURLToPath(import.meta.url));
45+
46+
/** The three commands that must hold a stack to the same author-time bar. */
47+
const AUTHORING_COMMANDS = ['validate.ts', 'lint.ts', 'compile.ts'] as const;
48+
49+
const sourceOf = (file: string) => readFileSync(join(commandsDir, file), 'utf8');
50+
51+
describe('reference-integrity wiring (#3583 §5 D5, #4384)', () => {
52+
it.each(AUTHORING_COMMANDS)('%s runs the suite', (file) => {
53+
expect(sourceOf(file)).toMatch(/\bvalidateReferenceIntegrity\s*\(/);
54+
});
55+
56+
// The regression itself. A second, per-rule call site is how a rule ends up
57+
// on two commands out of three, and it always starts with importing that rule
58+
// by name — so the import is what this asserts on. Catching the call instead
59+
// would miss the window between adding the import and adding the second call.
60+
it.each(AUTHORING_COMMANDS)('%s does not import a suite member directly', (file) => {
61+
const source = sourceOf(file);
62+
const reached = REFERENCE_INTEGRITY_RULES.map((r) => r.name).filter((name) =>
63+
new RegExp(
64+
String.raw`^\s*import\s[^;]*\b${name}\b[^;]*from\s*['"]@objectstack/lint['"]`,
65+
'm',
66+
).test(source),
67+
);
68+
expect(
69+
reached,
70+
`${file} imports suite member(s) directly — run them via validateReferenceIntegrity instead, ` +
71+
`or all three commands will drift the way validateReadonlyFlowWrites did (#4394)`,
72+
).toEqual([]);
73+
});
74+
75+
// Guards the guard: were the suite ever emptied or the export renamed, the
76+
// assertions above would pass vacuously while checking nothing.
77+
it('the suite is non-empty, so the assertions above are not vacuous', () => {
78+
expect(REFERENCE_INTEGRITY_RULES.length).toBeGreaterThan(0);
79+
// The rule whose absence from `os lint` is the reason this file exists.
80+
expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toContain('validateReadonlyFlowWrites');
81+
});
82+
});

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,7 @@ export type {
5353
RouteHandler,
5454
Middleware,
5555
IDataEngine,
56+
IObjectQLEngine,
57+
EngineSchemaRegistryView,
5658
IDataDriver,
5759
} from '@objectstack/spec/contracts';

0 commit comments

Comments
 (0)