Skip to content

Commit 0ab640d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22491-chart-view-needs-dataset
2 parents 1b5e733 + 4638625 commit 0ab640d

20 files changed

Lines changed: 1516 additions & 189 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev` prints the server's ready banner whole, then the MCP connect block whole, instead of interleaving the two (#22410)
6+
7+
Clause-②: no
8+
9+
- **What a terminal showed.** `os dev` is two processes writing one terminal. The parent prints the MCP connect block (`🤖 MCP server — connect a coding agent:` with its Endpoint, Skill, Connect and Disable rows) when the `serve` child sends `objectstack:listening`, and the child sent that message before it printed its ready banner. Measured under a pty on the Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block above or inside the banner. In 2 of them, banner lines landed between the block's rows, in one case the `➜ Console:` and `➜ MCP:` rows. A setup boot before those seven printed the `🔑 Dev admin` credential lines and the whole plugin section between `Skill` and `Connect`.
10+
- **What changed.** The child now sends `objectstack:listening` after its ready banner and boot diagnostics have printed. The parent prints the block on that message, so it always comes last, under the banner's `Press Ctrl+C to stop` row. The order follows from the sequence itself, with no timer: the banner is written to the terminal before the message is sent. After the change, every measured boot printed the banner whole and then the block whole.
11+
- **What did not change.** The message is still `{ type: 'objectstack:listening', port, url }`, carrying the port actually bound. The runtime state file is still written before either announcement, and `objectstack:seed-settled` still follows `objectstack:listening`. A program that spawns `os serve` with an IPC channel receives `objectstack:listening` slightly later than before: after the banner has printed instead of before.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`.
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-text-slot-unbound-dollar-root-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
**Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one.
15+
16+
**What is refused.**
17+
18+
- A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`.
19+
- `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key.
20+
- `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it.
21+
- `objectstack validate` reports `expression-invalid` at `error`.
22+
- The remedy for `{{ $User.<path> }}` is the sentence `{$User.<path>}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other root is named in the refusal, beside the variables the engine does bind.
23+
- A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy.
24+
25+
**Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable.
26+
27+
**`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses.
28+
29+
## FROM → TO
30+
31+
| you wrote | write instead |
32+
|:--|:--|
33+
| `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: '{$User.Id}' }`, then `message: 'By {{ by }}'` |
34+
| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` |
35+
36+
**The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.**
37+
38+
**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured.
39+
40+
### The kit
41+
42+
- **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one.
43+
- **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`security/explain` answers a delete and a transfer of a `controlled_by_parent` record the way each verb's own door answers it: their record verdicts now come from the master-detail write check (ADR-0055), as the update's already did.
6+
7+
- **What was wrong.** The write path runs the master-detail write check on every by-id write of such a record (an update, a delete, a transfer), and hands the record's platform ownership floor over to that check. explain asked the check for an update only.
8+
- A delete kept its `owner_only_deletes` floor. A principal who may edit the master but did not create the record was explained as refused (`decidedBy: 'rls'`) beside a `DELETE` that answers `200`. A principal who may not edit the master was refused on that floor, with no word about the master, beside a `DELETE` whose `403` names the master.
9+
- A transfer asked only the sharing service's per-record gate, which abstains on such a record. A principal who may not edit the master was explained as allowed to transfer it, beside the `PATCH` that writes `owner_id` and answers `403`.
10+
- **What it does now.** For an update, a delete or a transfer of a record that exists (and a restore or a purge, which the object gate still refuses to everyone), explain asks the security service's `checkControlledByParentWrite` with the context it is explaining. The check judges edit access to the master whatever the record's own verb, so its answer is each verb's answer. A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the verb and the refusing leg (or the reason no verdict was reached). A delete's row-level composition carries the write path's master-gate coverage vouch, as an update's does, so the ownership floor is handed over as the write path hands it, and never on behalf of a delegator.
11+
- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. A `create` asks no master check: the write path reads an insert's master from the request body, which an explanation does not carry. The `ExplainEngineDeps` type: the `checkControlledByParentWrite` dependency keeps its signature and is now asked for every by-id write, not only an update.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -731,10 +731,15 @@ export interface BoundPortChannels {
731731
* either announcement below: it is the file both of them send a consumer to.
732732
*/
733733
writeRuntimeState: (published: { port: number; url: string }) => void;
734-
/** Sends {@link ListeningMessage}, when an IPC channel is open. */
735-
announceListening: (message: ListeningMessage) => void;
736-
/** Prints the ready banner, whose `API:` row names the same address. */
734+
/**
735+
* Prints the ready banner, whose `API:` row names the same address, and the
736+
* boot diagnostics under it. ⛔ Must COMPLETE before the IPC message below:
737+
* the `os dev` parent prints its MCP connect block on that message, into the
738+
* same terminal (#22410).
739+
*/
737740
printBanner: () => void;
741+
/** Sends {@link ListeningMessage}, when an IPC channel is open. LAST — see above. */
742+
announceListening: (message: ListeningMessage) => void;
738743
}
739744

740745
/**
@@ -770,6 +775,25 @@ export interface BoundPortChannels {
770775
* into every consumer forever and hides it from the one place that can fix it.
771776
* The producer owns the ordering: **write the file, THEN announce it.**
772777
*
778+
* ## The second race: two processes, one terminal (#22410)
779+
*
780+
* The IPC message is not only a fact for a program. The `os dev` parent prints
781+
* its MCP connect block (`🤖 MCP server — connect a coding agent:` and its
782+
* Endpoint / Skill / Connect / Disable rows) the moment the message arrives,
783+
* into the terminal this process writes its banner to. Sent BEFORE the banner,
784+
* the two processes wrote that terminal at once: measured under a pty on the
785+
* Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block
786+
* above or inside the banner, 2 of them with banner lines inside the block, and
787+
* one probe boot printed the whole credential and plugin section between
788+
* `Skill` and `Connect`.
789+
*
790+
* So the banner comes SECOND and the message LAST. That makes the order
791+
* causal, not timed: the banner is written synchronously (a terminal is
792+
* synchronous stdio on POSIX, and `keepStderrNonBlocking` leaves a TTY
793+
* alone), so every banner byte is in the terminal before the message is sent,
794+
* and the parent prints only on receipt. ⛔ No sleep or timer orders anything
795+
* here. The message's shape is unchanged; it arrives a banner's print later.
796+
*
773797
* ## Why the channels are injected rather than called inline
774798
*
775799
* The ORDER is the contract here, and an order is only pinned by a test that
@@ -796,11 +820,12 @@ export function publishBoundPort(
796820
const url = `${boundProtocol}://localhost:${boundPort}`;
797821
// 1 ─ THE FILE FIRST. Both announcements below send a consumer to it.
798822
channels.writeRuntimeState({ port: boundPort, url });
799-
// 2 ─ IPC: the `os dev` parent learns the real port without polling.
800-
channels.announceListening({ type: 'objectstack:listening', port: boundPort, url });
801-
// 3 ─ The banner: a human, or a supervisor tailing stdout, reads the same
823+
// 2 ─ The banner: a human, or a supervisor tailing stdout, reads the same
802824
// address — and by now the state file it names is on disk.
803825
channels.printBanner();
826+
// 3 ─ IPC LAST: the `os dev` parent learns the real port without polling,
827+
// and prints its MCP connect block under a banner that has finished (#22410).
828+
channels.announceListening({ type: 'objectstack:listening', port: boundPort, url });
804829
}
805830

806831
/**
@@ -5331,14 +5356,16 @@ export default class Serve extends Command {
53315356
// one so supervisors and the `os dev` parent never have to guess:
53325357
// • runtime.json: a small state file under OS_HOME for external
53335358
// supervisors / health checks (pid + port + url).
5359+
// • the ready banner, whose `API:` row names the same address.
53345360
// • IPC: when spawned with an 'ipc' channel (as `os dev` does), the
53355361
// parent learns the real port without polling.
5336-
// • the ready banner, whose `API:` row names the same address.
53375362
//
53385363
// ⭐ That list is in ORDER, and the order is the whole point (commit faff497fd):
53395364
// the file is written BEFORE either channel announces the address that
5340-
// sends a consumer to it. {@link publishBoundPort} carries the race the
5341-
// old order lost, and the reason the repair is not reader-side polling.
5365+
// sends a consumer to it, and the banner is printed whole BEFORE the
5366+
// message that makes the `os dev` parent print into the same terminal
5367+
// (#22410). {@link publishBoundPort} carries both races the old orders
5368+
// lost, and the reason neither repair is a reader-side poll or a timer.
53425369
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol);
53435370

53445371
// ── …and one beat later, whether the APP is ready too (#17329) ──

0 commit comments

Comments
 (0)