Skip to content

Commit 1206886

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19086-fix-builds-what-it-writes-from
2 parents bebceba + d00692f commit 1206886

15 files changed

Lines changed: 2230 additions & 18 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
**The permission-set seeder's unowned refusal now moves a counter.** `PermissionSeedOutcome` gains a required `skippedUnowned`, incremented on the `!packageId` branch of `upsertPackagePermissionSet` — the branch that refuses to materialize a declared set with no resolvable owner (#18571).
6+
7+
⛔ **The refusal itself is unchanged.** A `managed_by:'package'` row with no `package_id` makes uninstall undefined, which is exactly the ADR-0086 D3 ambiguity the branch exists to prevent. This card adds a channel, not a verdict.
8+
9+
#18564 gave that refusal its author-visible line. What it left is the programmatic half: the outcome came back with all six counters at zero, so a caller that reads no log at all — a boot report, a Setup surface, a test — could not tell a pass that refused a declaration from a pass with nothing to do. Measured against the sibling axis, which has counted the same refusal at the same boundary since #4967:
10+
11+
```
12+
axis unowned refusal counter moved author-visible line
13+
capability skippedUnowned 1 1
14+
permission set (before) — none declared — 0 1
15+
permission set (after) skippedUnowned 1 1
16+
```
17+
18+
**Required, not optional** — like `skippedForeign` and unlike `deleted`. An absent key on a *refusal* count reads exactly like a pass with nothing to refuse, which is the defect restated. All four construction sites initialize it (`bootstrapDeclaredPermissions`, `upsertPackagePermissionSet`, `upsertEnvPermissionSet`, `retirePermissionSetRecord`), so every door that returns this outcome answers the question.
19+
20+
**Both doors onto the branch increment it.** The boot catalog loop aggregates it alongside the five counters it already forwarded; the ADR-0086 P2 publish materializer passes no collector and returns its own outcome, so a fix wired only into the aggregation would have left that caller as silent as before.
21+
22+
**The accounting closes.** `seeded + updated + unchanged + skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is now the number of named declarations a pass read — pinned by a conservation test modelled on the capability axis'. Before this counter that sum was short by every unowned declaration. (Pre-existing caveat, unchanged and outside this change: a write the engine rejects increments no counter; it is reported through `SeedWriteRefusals`.)
23+
24+
New published surface on `@objectstack/plugin-security`: the `skippedUnowned` member of the barrel-exported `PermissionSeedOutcome`. Reading an outcome is unaffected — the member only adds a number to read.
25+
26+
The sweep for construction sites covered this repository, the pinned `objectui` checkout and the downstream app repositories available to it, and found none outside the package. That is what was measured, and it cannot speak for a consumer outside those trees. So: if you construct a `PermissionSeedOutcome` yourself — a test double standing in for the seeder is the shape that does — add `skippedUnowned: 0`. Nothing else changes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for callers** — `parseFilterAST` now refuses a blank `$between` endpoint, exactly as the authoring schema already does. An empty-string or absent (`undefined`) bound, at either side, is refused with `INVALID_FILTER` / 400, and the refusal names the blank side — MIN or MAX, plus the index (#19071).
6+
7+
Clause-②: no
8+
9+
## What changed, and why it is the implementation catching up rather than a new rule
10+
11+
`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated since 2026-09-17 that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the authoring schema only. The runtime door disagreed with it: `parseFilterAST({ at: { $between: ['', ''] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after.
12+
13+
One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one that matters most here. A caller that lowers a filter with `parseFilterAST` and hands it straight to a driver (an embedder; this repo's own driver conformance suites) never meets the schema. At every backend a blank bound stops bounding on that side while the range still reads as a complete two-element range, so the query runs with one meaningless boundary and returns rows outside the window its filter names, with no signal at any layer.
14+
15+
```
16+
FROM parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
17+
-> { at: { $between: ['2026-01-01', ''] } } // unchanged, same reference,
18+
// straight on to the driver
19+
20+
TO parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
21+
-> throws INVALID_FILTER / 400:
22+
'Operator "$between" on field "at" requires two non-blank bounds.
23+
Received an empty string at where.at.$between[1] (the MAX bound). …'
24+
```
25+
26+
## Migration — FROM → TO
27+
28+
| You wrote | Write instead |
29+
| --- | --- |
30+
| `{ $between: ['2026-01-01', ''] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the bound you meant, written out |
31+
| `{ $between: ['', 100] }` | `{ $between: [0, 100] }` — the lower bound you meant |
32+
| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range |
33+
34+
**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** The same prescription the authoring door already gives, now given at the door an embedder actually reaches.
35+
36+
## What does NOT change
37+
38+
- **`null` bounds** keep their own message, refused since 2026-08-31. It prescribes the null PREDICATE, because an author who wrote `null` was reaching for absence and an author who left a bound empty was reaching for a bound — two blank spellings, two intents, two remedies. `null` is also checked first, so a pair that is blank on one side and null on the other keeps the message it has always had.
39+
- **Whitespace-only endpoints** are still accepted, at BOTH doors. The 2026-09-17 ruling is the empty string; the authoring door pins `{ $between: [' ', 'M'] }` as parsing green on purpose, and trimming here would re-open the very split this change closes, in the opposite direction.
40+
- **Falsiness.** `{ $between: [0, 0] }` and `{ $between: ['0', '9'] }` lower exactly as before. The rule is blankness, not falsiness.
41+
- **`$in` / `$nin` members.** A falsy or empty-string MEMBER is a value, not an absence (2026-08-31, `filter-comparand-shape.test.ts`). Only a range ENDPOINT is judged here, and only the `$between` row of that pin moves.
42+
- **Arity**, which was already refused with its own message, and every legal range: numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference.
43+
- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged.
44+
45+
<!-- adr-0087: not-required (already-registered filter-between-blank-endpoint-refused) the transition from a blank $between endpoint to two present, non-empty bounds was registered by #18012 for this same surface set; this change adds no new transition, it brings the runtime lowering face under the one already on the ledger, and the entry's own prescription and acceptance criteria apply verbatim -->
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime): `POST /api/v1/packages` parses the manifest's `id` leg instead of reading it positionally (#19417)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install door** — a manifest whose `id` is not
10+
reverse-domain notation is now refused `400` / `VALIDATION_ERROR`. It used to
11+
install and answer `201`.
12+
13+
The accept set only shrinks back to what the published declaration has always
14+
said. `MANIFEST_ID_PATTERN` is declared once in
15+
`packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one
16+
identity — `ManifestSchema.id`, what an author writes, and
17+
`PackageSchema.manifestId`, what the registry stores and publishes by. The door
18+
read `manifest.id` POSITIONALLY (`typeof manifest?.id === 'string' ?
19+
manifest.id.trim() : ''`) and parsed nothing, so `id: 'pkg-a'` installed and
20+
answered `201` while `defineStack()`, `os build`, `os validate` and the publish
21+
face all refused the same id. The author was handed a package that could never
22+
be rebuilt or published. That is «declared ≠ enforced» on a published API
23+
contract — and nothing in `packages/spec` moves for it: the declaration was
24+
already right.
25+
26+
The gate asks the declaration **by reference** — `ManifestSchema.shape.id` —
27+
rather than keeping a copy of the grammar, exactly as the `version` leg beside
28+
it does, so a future move of the reverse-domain rule reaches this door with no
29+
further edit. The sentence the caller reads is the declaration's own
30+
(`manifestIdRefusal`), **surfaced rather than reworded**: it names the key,
31+
echoes the value, lists the two examples, and carries a suggestion arm that
32+
verifies its candidate against the pattern before offering it. Posting
33+
`id: 'pkg-a'` now answers, in the response envelope's `error.message`:
34+
35+
```text
36+
Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation
37+
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
38+
hyphens allowed inside a segment, underscores are not. Did you mean
39+
'com.example.pkg-a'?
40+
```
41+
42+
**What is not affected.** Boot-time and in-process installs reach
43+
`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass
44+
through this branch, so nothing about how a package is loaded from disk or
45+
registered by a plugin changes. A conforming manifest installs exactly as
46+
before, on both body forms (wrapped and bare) and on both install limbs (the
47+
protocol primitive and the bare-registry fallback).
48+
49+
**The `Package id is required` sentence does NOT move.** `''` fails
50+
`MANIFEST_ID_PATTERN` too, so where this gate sits decides whether a published
51+
message changes or only the accept set does. It is ordered AFTER the existing
52+
`!pkgId` check: an absent, empty, whitespace-only or non-string `id` still
53+
answers `400 Package id is required`, never the schema's sentence — which on
54+
that input is the one case where the suggestion arm has nothing to offer.
55+
Measured both directions. It is ordered BEFORE the `version` gate for the
56+
mirror-image reason: that refusal's sentence names the id it prescribes for, and
57+
prescribing a `version` repair for an id that can never be legal sends the
58+
author round twice.
59+
60+
**Scope — the `id` leg alone.** The declaration's residual docblock records the
61+
classes this door answers `201` to while `PackageInstallBodySchema` refuses
62+
them. This change closes one: `id`. A missing `type`, unknown keys on either
63+
body form, a string-typed `enableOnInstall` / `overwrite`, and install options
64+
spelled on the bare form are each left exactly as they were — measured after the
65+
change, all seven spellings of those four classes still answer `201`, against a
66+
`pkg-a` control that answers `400` and a conforming control that answers `201`.
67+
Each is its own reading and its own card. Closing them is the one call this
68+
handler still pointedly does not make, `PackageInstallBodySchema.safeParse(body)`.
69+
70+
**If you are refused.** Give the manifest an id in reverse-domain notation —
71+
lowercase dot-separated segments, hyphens allowed inside a segment, underscores
72+
not. The refusal names the key, echoes what you wrote and, where a mechanical
73+
repair exists, offers one it has already checked against the rule, so the
74+
prescription arrives with the `400` rather than in a changelog.
75+
76+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. `objectstack migrate meta` has nothing to reach, because there is no old spelling that maps to a new one — a caller supplies an id the declaration already required, and an id's repair changes the package's identity, so no mechanical mapping could be prescribed even in principle. The refusal itself carries the remedy. -->

‎.claude/settings.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
"Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)",
5353
"Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)",
5454
"Bash(node --use-env-proxy scripts/pm/label-write.mjs *)",
55+
"Bash(node scripts/pm/post-stamped.mjs *)",
56+
"Bash(node scripts/pm/label-write.mjs *)",
57+
"Bash(node scripts/pm/close-cards.mjs *)",
58+
"Bash(node --use-env-proxy scripts/pm/close-cards.mjs *)",
5559
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/issues/* *)",
5660
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/issues/* *)"
5761
],

‎.github/workflows/lint.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,29 @@ jobs:
13071307
- name: Label-write helper self-test
13081308
run: pnpm check:pm-label-write
13091309

1310+
# Card-closure helper self-test (#19469) — same family and the same split
1311+
# as the two steps above, and it drives BOTH of them: `close-cards.mjs` is
1312+
# the three-step closure (comment · label · close) as one named command,
1313+
# so a seat's batch closure is one allow-listed prefix instead of a loop
1314+
# the session's write classifier judges act by act. The LIVE path posts
1315+
# comments, strips labels and closes cards, so it is emphatically not a
1316+
# thing CI runs against the board; the offline half is all CI can hold.
1317+
#
1318+
# What the self-test instruments is the set of rules a clean tree cannot
1319+
# exercise, every one of which is silent when it breaks: the skip matrix
1320+
# (not open · assigned · `pm:retriage` · a pm-state that is not EXACTLY
1321+
# the expected one · an open PR cross-referencing the card); that an
1322+
# unreadable card or timeline STOPS the run rather than being read as a
1323+
# "no"; that a 200 whose body does not read back `closed`, or reads back
1324+
# another `state_reason`, is not a close; and — the one this tool exists
1325+
# for — that a comment which lands whose label write then fails STOPS at
1326+
# that card, naming it and which of the three writes landed, instead of
1327+
# turning one half-state into a page of them. Weakening any of them leaves
1328+
# every ordinary invocation just as green, which is the shape
1329+
# `check:self-test-wired` requires this step for.
1330+
- name: Card-closure helper self-test
1331+
run: pnpm check:pm-close-cards
1332+
13101333
# Board-snapshot archiver self-test (#17390) — same family and the same
13111334
# split as every step around it. The LIVE run reads the whole board over
13121335
# REST and commits the result to an orphan branch, so it is not a thing

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test",
8989
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
9090
"check:pm-label-write": "node scripts/pm/label-write.mjs --self-test",
91+
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",
9192
"check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test",
9293
"check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test",
9394
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",

0 commit comments

Comments
 (0)