Skip to content

Commit 3b524cc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19273-enableoninstall-optional
# Conflicts: # packages/spec/src/kernel/package-registry.zod.ts
2 parents b3ec212 + 28f9277 commit 3b524cc

36 files changed

Lines changed: 2128 additions & 123 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`src/migrations/entries/README.md` — the ADR-0087 entry authoring rules now record that an entry's prose is scanned as source **twice**, and that the rule is never to spell a shape a live textual ratchet matches (#15130).
6+
7+
This file ships inside the package (`files[]` carries `README.md`, which matches at every depth — measured with `npm pack --dry-run`: 277 files, this one among them), so the rules an entry author reads are these.
8+
9+
- **The mechanism is the counter-intuitive part.** Every string an entry declares — `surface`, `replacement`, `reason`, `acceptanceCriteria` — is concatenated verbatim into the generated `src/migrations/registry.ts`, which is ordinary `.ts`. A repo-wide textual scan therefore reads the same sentence once in the entry file and once in the registry. The tree's one code/prose separator masks **comments** and leaves **string literals** intact by design, so a quoted example is code to every scan built on it, and prose in this package can turn **another package's** test red.
10+
- **The rule is the broad one, and the parenthesis is its instance.** A rule worded as "quote a retired call site without its parentheses" would make counter-examples of entries that spell a parenthesised call and are green — they go unmatched only because no live ratchet enumerates *those* methods, which is a fact about today's ratchets rather than a licence. What an author controls is not spelling a shape some ratchet matches; the guidance is to name the surface rather than spell a call of it.
11+
12+
No schema, no export and no authorable key moves; the three existing rules in that section are unchanged and no entry was edited.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A package body now has a declaration at every stage it really passes through: `ArtifactStagePackageBodySchema` and `RecordStagePackageBodySchema` join `AssembledPackageBodySchema`, and the installed-package read rows are declared against the record stage instead of two `z.unknown()` holes (#17518).
6+
7+
ADR-0130 D4 says an artifact is inert JSON — "a plugin written inside `packages[i].manifest` could never be constructed by a loader, so a reader that resolved it there would register garbage where it used to skip in silence". Of `AssembledPackageBodySchema`'s 55 members exactly two declare that they accept a callable: `functions`, whose entry union opens with `z.function()`, and `hooks`, whose `handler` carries a `z.custom()` branch. One unrepresentable member costs every embedder its whole JSON Schema, which is why `api/ListInstalledPackagesResponse` and `api/GetInstalledPackageResponse` could only carry the body with both keys written `z.unknown().optional()` — accepted without being checked, as that file's own docblock said.
8+
9+
- **⛔ The assembled body is untouched, and that is the point.** Those callables are LIVE on the stage it declares itself for: `composeStacks(stacks, { manifest: 'preserve' })` builds exactly such a body and the load path registers it, and `stack.zod.ts` states the invariant that binds the two. Narrowing in place would refuse a published composition function's own output. The two JSON stages are declared BESIDE it instead.
10+
- **Artifact stage** — what `objectstack build` writes: `functions` entries are the lowered spellings (a bare handler ref, or `FlowFunctionLoweredDeclarationSchema`), `hooks[].handler` is a string. **Record stage** — what `SchemaRegistry.installPackage` stores: the artifact stage with `functions[].handler` OPTIONAL, in both the map-record and the array form. That single difference is the whole distance between the two: `build` mints a ref for every callable, while `toRecordManifest`'s structural projection DROPS the callable and mints nothing in its place, so a record states what each function is named and what it declared with `handler` absent where the callable was. Measured: both bodies convert under `z.toJSONSchema` over the whole body, where the assembled body still does not.
11+
- **`FlowFunctionLoweredDeclarationSchema` is exported** from `@objectstack/spec/automation`, with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `export * from './flow-function.zod'` only re-exports what is already exported — so `unemitted-schemas.baseline.json`'s reason for `Automation.FlowFunctionDeclarationSchema`, which says the lowered record "is the serialisable half … and it publishes normally", pointed at a schema no consumer could reach. It publishes now: `automation/FlowFunctionLoweredDeclaration` is in the schema manifest.
12+
- **`effect` is READ, not minted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default('pure')` — a default, not a requirement — and the array member's is `.optional()` with no default. Both JSON stages inherit each form's optionality by deriving from it rather than restating it.
13+
- **⚠️ What narrows, stated plainly**: on the two installed-package responses, `functions` and `hooks` move from `unknown` (accepts anything) to their declared JSON shapes. No row the doors really serve is withdrawn — measured through the real `SchemaRegistry.installPackage` on the shape `examples/app-showcase` ships, on the array form, and on the already-lowered body an artifact boot installs. Every other key, `objects` included, is checked exactly as before, and both stages still refuse an authoring glob and an unknown key.
14+
- One correction in the same edit: `package-api.zod.ts` said those two members were also why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema. They are not — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
`GET /packages` reports every function a package declares. A bare callable `functions` entry is normalised to the declared form at the assembly boundary, so the registry record no longer drops it (#17518).
6+
7+
`SchemaRegistry.installPackage` stores `toRecordManifest(manifest)`, a structural JSON projection whose rule is "a live object reached the record" and deliberately ⛔ not a key denylist. That rule treated the two authored `functions` spellings unequally through no fault of its own: a DECLARED entry (`{ handler, effect: 'writes' }`) is a plain object, so it survived with its callable dropped, while a BARE callable entry IS the callable, so the whole key vanished. `examples/app-showcase` ships one of each, so a package declaring two functions was reported as declaring one — a machine-readable read door under-reporting by construction.
8+
9+
- **The repair is at the assembly boundary, ⛔ not in the projection.** `installPackage` makes the two spellings structurally equal before projecting, so the structural rule is untouched and no key name is special-cased. The projection then leaves `{ effect }` for both.
10+
- **⛔ No ref is minted.** `objectstack build` mints refs with `uniqueName(base, taken)` and dedupes by function identity, so a ref minted in the registry is not guaranteed to be the one `build` mints — a record could assert a handler that resolves in no sibling module. An absent `handler` is the honest statement "declared here, not serialisable", which is exactly what `@objectstack/spec`'s new `RecordStagePackageBodySchema` declares.
11+
- **⛔ No entry is dropped**, either: under-reporting by design was the other arm, and it also throws away the `effect` declaration, the one half that survived.
12+
- The caller's manifest is never mutated — `ObjectQL.registerApp` and the hook binder read the live callables off that object — and a copy is made only when an entry really needed rewriting. The ARRAY form is untouched: its entries are objects carrying their own `name`, so the projection already kept them.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`kernel/InstallPackageRequest.enableOnInstall` no longer tells authors the in-process primitive ignores the key — it now states the three states that primitive really applies (#19339).
6+
7+
The declaration's published description read "this protocol primitive does not read it". That was true when it was written and stopped being true when `MetadataProtocol.installPackage` started honouring the key (`482d584121`): `true` enables, `false` disables, and an ABSENT key makes no lifecycle call at all. Nothing went red, because `check:docs` holds the generated reference page equal to the `.describe()` and the two still agreed with each other — internal consistency, not truth.
8+
9+
Clause-②: no
10+
11+
**What moves**
12+
13+
The `.describe()` text of one key, the doc block above it, and the two reference pages generated from that text (`references/api/protocol.mdx`, `references/kernel/package-registry.mdx`). It now reads: "restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: true enables, false disables, absent makes no lifecycle call".
14+
15+
The scope word "on the registry row" is load-bearing and is spelled out in the doc block: the durable disabled-package file is keyed by environment, which an `InstallPackageRequest` does not carry, so this seam moves the registry row for the life of the process and `POST /api/v1/packages` still owns the record that survives a restart.
16+
17+
**What does not move**
18+
19+
No key is added, removed, renamed or retyped, and no default changes — the accept set is byte-for-byte what it was, and `api-surface`, `authorable-surface` and `authorable-defaults` are all unchanged. `PackageInstallRequestSchema` (`api/package-api.zod.ts`) remains the one authority for this key, and the parity pin that holds the copy to it is untouched.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
`backfillCanonicalDatetimes` / `backfillCanonicalTimes` no longer write SQLite's julian-day reading of a bare-numeric cell over the bytes on disk (#6009).
6+
7+
Both migrations use the read path's own repair expression as their `SET` value, and that expression's `else` arm is `coalesce(strftime(...), col)` — on the stated understanding that a value SQLite cannot parse falls through the `coalesce` unchanged. SQLite's time-value grammar has one limb that defeats it: a **bare number** is a julian day (`DDDD.DDDD`, the last of its documented formats), and `now` is the wall clock. For those two shapes `strftime` does not return NULL, so `coalesce` never fires and a confident wrong answer is what gets written. Measured on better-sqlite3 13.0.3 / SQLite 3.53.4 against a TEXT-affinity column:
8+
9+
```text
10+
'12' -> -4713-12-06T12:00:00.000Z '2026' -> -4707-06-11T12:00:00.000Z
11+
'86400' -> -4476-06-15T12:00:00.000Z '2440587.5' -> 1970-01-01T00:00:00.000Z
12+
'now' -> whatever the clock said '2026-08-06' -> 2026-08-06T00:00:00.000Z (correct)
13+
```
14+
15+
On a read that was a temporary misreading and the disk was untouched. On the `SET` side it is a write, and the original value is unrecoverable afterwards.
16+
17+
- **The guard is the structural complement of the parseable spellings, not a heuristic.** Every other format SQLite's date parser accepts goes through `parseYyyyMmDd` (which needs a `-`) or `parseHhMmSs` (which needs a `:`), so a cell the parser answers for while containing neither character reached it through the julian-day limb or the `now` limb — there is no third way in. `sqliteNonTemporalTextSql` is that predicate; it never inspects the magnitude of the number and never decides what the cell means, only that the migration must not rewrite it.
18+
- **A withheld row also blocks the canonical mark, and that is what keeps every query answer identical.** Skipping the row costs nothing while the read-side repair still applies to it — it keeps reading as the same instant it always did. Marking the column clean is what would move an answer: `needsLegacyDatetimeRepair` would drop the repair and the raw `'2026'` would be compared as TEXT. Unlike the `coalesce` fixpoints, these rows are not invariant under the repair, so they cannot ride through the mark. The column stays un-marked, reads keep their (unindexed) repair, and one `warn` names the count, the reason and the remedy.
19+
- **The read path is untouched, deliberately.** The maintainer's 2026-08-03 ruling on cloud#1005 refused teaching the shared read expression to recognise numeric-looking text: it is a public contract for every SQLite consumer, it runs on every read, and there it would misread a legitimate numeric-string column. Everything here runs once, as a migration, only on a column the metadata declares `Field.datetime` / `Field.time`, and it only ever declines to write.
20+
- **`previewDatetimeConvergence` / `previewTimeConvergence` inherit the same exclusion**, so `os migrate plan` still promises exactly the rows `apply` rewrites.
21+
22+
Reaching the julian branch needs a temporal column with TEXT affinity. A column knex creates for `Field.datetime` is declared `datetime`, which is NUMERIC affinity, so `'2026'` is converted to INTEGER on the way in and takes the epoch branch instead — the local write path mostly dodges this. A table that already existed when `initObjects` first saw it keeps whatever affinity it was created with, and `initObjects` adds missing columns without ever retyping one.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family
6+
7+
`composeStacks` refuses six authored-entity conflicts, and until now every one of them threw
8+
`new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the
9+
same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two
10+
refusal families that are the same thing to an author — a stack refused at authoring time,
11+
through the same callers — and two different things to a consumer branching on `error.code`.
12+
13+
Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it
14+
is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`'
15+
own body, so reading the function the defect is named after finds one of them.
16+
17+
| refusal | raised by | code |
18+
| :--- | :--- | :--- |
19+
| a single-valued top-level key declared with different values by two stacks | `composeSingleValue` | `STACK_COMPOSE_KEY_CONFLICT` |
20+
| `functions` authored in the map form by one stack, the array form by another | `composeFunctions` | `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` |
21+
| two stacks defining one handler name | `composeFunctions` | `STACK_COMPOSE_FUNCTION_CONFLICT` |
22+
| under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `refuseUnmergeableCollections` | `STACK_COMPOSE_COLLECTION_CONFLICT` |
23+
| the same object name in two stacks under the default `objectConflict: 'error'` | `mergeObjects` | `STACK_COMPOSE_OBJECT_CONFLICT` |
24+
| a cross-stack action key collision | `collectComposedActionKeyCollisions` | `STACK_COMPOSE_ACTION_KEY_COLLISION` |
25+
26+
Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the
27+
findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte
28+
unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the
29+
message pins across the repo read the prose they always did.
30+
31+
One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the
32+
granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's
33+
boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the
34+
per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which
35+
is legal on its own, so the fix is in the composition rather than in one malformed stack.
36+
`STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its
37+
per-stack and artifact passes share one code because they are one rule family over two scopes.
38+
39+
All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that
40+
every code shipped in `dist` is the published face, door or no door. No wire door raises them:
41+
`composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero
42+
`composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test
43+
occurrences, all doc comments or message prose in one file), with `defineStack` lighting the
44+
same probe 31 times across 8 files as the positive control.
45+
46+
Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export
47+
changes — the error classes stay module-local, as every member of the `defineStack` family is,
48+
because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112
49+
contract is the `code` / `status` pair read structurally.
50+
51+
⛔ The seventh bare `Error` in that file is deliberately untouched:
52+
`composeStacks internal error: no source stack recorded for composed object …` is the code
53+
discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it
54+
at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a
55+
500-class envelope of its own is a separate decision.
56+
57+
Clause-②: yes

‎.claude/skills/pm-dispatch/references/landing-operations.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
- 契约复核 PASS 落地的 PR 到窗口时已 ready 且 auto-merge 在挂,见 `contract-review.md`。
2525
- 窗口自身权责不变:跟到 MERGED、踢出处置、落地后对账。
2626
- 转 ready/入队前跑 `check-governed-merges.mjs --pr N`:受管面照两层;>5000 行(含生成物)照规则层。
27-
- 受管路径全在本技能 `references/` 者事实层:席内达档复核过落地前检三条即转正式入队。
28-
- 其余为规则层:四件套留 draft 等人批,⛔ 不翻正式不入队;获授权批准后认领席落地。
27+
- 受管路径全在 `.claude/**` 者 Tier S:席内达档复核过落地前检三条即转正式入队。
28+
- Tier H(其余受管面)者:四件套留 draft 等人批,⛔ 不翻正式不入队;获授权批准后认领席落地。
2929
- ⛔ 两层不由席位批准;清标即落地同受此闸,漏判会被队列守卫在 merge group 里拒收。
3030
- 再读 `mergeable_state`:`dirty` ⇒ 先 merge `origin/main` 再挂;生成物在面上按 A 的固定序。
3131
- ready + 全绿 ≠ 已入队:队列从不主动拉 PR,入队是显式动作。

0 commit comments

Comments
 (0)