Skip to content

Commit 448bcb9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15442-filter-orthography-converge
2 parents 3d7f4ed + d64bcb6 commit 448bcb9

106 files changed

Lines changed: 8854 additions & 2607 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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433)
6+
7+
`ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus``completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever.
8+
9+
**Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`.
10+
11+
The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well.
12+
13+
No API change: the classification is module-internal and the package barrel is untouched.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes.
6+
7+
`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound).
8+
9+
Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence.
10+
11+
Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one.
12+
13+
New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`.
14+
15+
This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582)
6+
7+
`organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code:
8+
9+
```
10+
client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR)
11+
```
12+
13+
Omitting `role` now sends `'member'`. **No published type moves**`role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped).
14+
15+
The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for.
16+
17+
Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after:
18+
19+
```
20+
before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"}
21+
after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...}
22+
```
23+
24+
No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052)
6+
7+
`insert`, `update` and `delete` each end their `catch` with `throw e`, then
8+
logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log
9+
levels* names that exact shape and forbids it: "a failure handed to the CALLER
10+
is not a degradation at all … Do not bolt a `logger.error` onto such a site."
11+
12+
**This moves published behaviour**, which is why it is a changeset rather than a
13+
`skip-changeset`: the level is what an operator greps, and at least one consumer
14+
reads it structurally. `scripts/publish-smoke.sh` fails a boot on any
15+
error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was
16+
found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly`
17+
mode and documents its `identifier` UNIQUE constraint AS its race-safety
18+
mechanism, catching the collision and continuing at `debug`. Our line was
19+
emitted before that catch ever ran, so a healthy first boot of every fresh
20+
`create-objectstack` project printed `ERROR Insert operation failed` and red-lit
21+
`publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose
22+
auth and CRUD probes were all green.
23+
24+
**Nothing else about the entry moved.** Same message, same `object` meta, same
25+
redaction (#8682: the bound statement and its values stay cut from `message`
26+
and `stack`), same subject (#14095: the entry carries the driver's own error —
27+
a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column,
28+
MySQL's index name and the driver's frames survive). The `Logger` contract gives
29+
an `Error` slot to `error`/`fatal` only, so the engine now builds the
30+
`{ error: { message, stack } }` bag that slot used to build; handing the Error
31+
to `warn` as meta would have serialised `{}`, because those two fields are
32+
non-enumerable. The rendered line is byte-identical apart from the level word,
33+
and that equivalence is pinned rather than asserted.
34+
35+
If you grep your logs for these three messages, keep the message and drop the
36+
level from the pattern. If you alert on error-level lines from `@objectstack/objectql`,
37+
a refused write no longer raises one — the write's exception still does.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
"@objectstack/metadata": minor
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422)
7+
8+
## What was wrong
9+
10+
`#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside
11+
the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a
12+
valid JS `Date` becomes ISO text — and handed **every other input back
13+
untouched**. Four adapter boundaries used it, and each fed a field declared
14+
`z.string()` or `z.string().datetime()`:
15+
16+
| site | declared as |
17+
|:--|:--|
18+
| `SysMetadataRepository.rowToEvent``MetadataEvent.ts` | `z.string()` |
19+
| `DatabaseLoader.rowToRecord``MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` |
20+
| `DatabaseLoader.getHistoryRecord``MetadataHistoryRecord.recordedAt` | `z.string().datetime()`**required** |
21+
| `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()`**required** |
22+
23+
So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a
24+
field declared `string`, each wearing an `as string` / `as string | undefined`
25+
cast that asserted the opposite. Measured over the seven inputs that
26+
distinguish the two helpers, the declared schemas refused **21 of 35** produced
27+
values.
28+
29+
`recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which
30+
none of the three available answers was legal — the visible text
31+
`"Invalid Date"` fails the refinement, `undefined` fails the required field, and
32+
the pass-through fed it the `Date` object, which fails both.
33+
34+
## What it does now
35+
36+
Those four sites read `canonicalIsoInstant`, whose return type **is**
37+
`string | undefined`, so all four casts are deleted rather than restated. Both
38+
sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen
39+
per site, from the site's own declared schema:
40+
41+
- `MetadataRecord.createdAt` / `.updatedAt` are `.optional()``undefined`, the
42+
branch an absent column already took. ⛔ No default is invented for a field the
43+
schema lets be absent.
44+
- `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named
45+
`recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a
46+
`now` stamp is a plausible-looking recording instant nobody measured, and it
47+
sorts a version recorded years ago to the top of a newest-first timeline. The
48+
epoch invents no fact and sorts to the oldest end. It is also the answer the
49+
sibling reader of this same `sys_metadata_history.recorded_at` column already
50+
gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`).
51+
52+
Schema refusals over the same seven inputs: **21 → 8**. The eight that remain
53+
are a `number` and an opaque object at four sites — shapes no driver is measured
54+
to materialise for these columns. They now arrive as the declared *type* (a
55+
string) that simply is not a valid datetime, so the producer's bug stays visible
56+
instead of being papered over.
57+
58+
## One behaviour change worth reading twice — and it is why this is `minor`
59+
60+
`DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An
61+
Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish —
62+
so a row with an unreadable `updated_at` and a good `created_at` published
63+
`new Date()` as its `mtime`. It now folds to `undefined` one step earlier and
64+
loses the `??`, so the row publishes its `created_at`: a stored instant in place
65+
of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column
66+
takes" that `#14078`'s own ruling text prescribes for the shape.
67+
68+
⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies
69+
`MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the
70+
pre-existing pin asserted exactly that. So this one site is **not** the repair of
71+
a violation — it is one legal published answer replaced by a different legal
72+
published answer on a published read verb. Nothing was refused before and is
73+
permitted now; a consumer simply receives a different instant.
74+
75+
## Why the two levels differ
76+
77+
- **`@objectstack/metadata``minor`.** Its four repaired sites, on their own,
78+
are the "repairing an implementation that silently violated its own already
79+
published declared type" case: the values that changed there are ones
80+
`MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and
81+
nothing a consumer legitimately received has moved. But this package also
82+
carries `stat()`, and that site changes a **legal** published answer, which the
83+
paragraph above measures. The level is per package, so the four repaired sites
84+
ride along at `minor`.
85+
- **`@objectstack/metadata-protocol``patch`.** Neither of its two sites moves
86+
a legal published answer. `rowToEvent` only stops emitting values
87+
`MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a
88+
field declared `z.string()`), and `listCommits` is byte-identical on all seven
89+
probe inputs.
90+
91+
⛔ No declared type narrowed, no export was added or removed (neither helper was
92+
ever exported), and no envelope or accept set moved — so this is `minor` by the
93+
changed-answer row, not a breaking change, and it carries no ADR-0087
94+
disposition.
95+
96+
## What deliberately did NOT collapse
97+
98+
`listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock
99+
promises callers the RAW value back for a non-`Date`, and the shared spelling
100+
rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the
101+
response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently
102+
drops a value that is on disk) and hand a `number` or an opaque object to the
103+
commit-timeline sort as `String(value)` rather than verbatim. Measured, that site
104+
is byte-identical on all seven inputs before and after this change.
105+
106+
`SqlDriver`'s same-named helper is not part of this family at all: it takes
107+
`Date` (not `unknown`), both its call sites narrow with `instanceof Date` first,
108+
and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729)
8+
9+
## What was wrong
10+
11+
`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever
12+
schema THAT call happened to carry. A second registration without a `tenancy`
13+
block — the `{ name, fields }` shape — fell through to the implicit
14+
`organization_id` heuristic, so a `unique` field moved from **one row per
15+
install** (`scopeField: null`, which is what `tenancy.enabled: false` declares)
16+
to **one row per organization**. A duplicate the declaration refuses then
17+
landed. Measured at the driver door on `origin/main` `d61139f1ba`:
18+
19+
| sequence | second `key: 'K'`, different organization |
20+
|:--|:--|
21+
| register with `tenancy.enabled: false` | `REFUSED``UNIQUE_VIOLATION` / 409 |
22+
| …then re-register with `{ name, fields }` | **`LANDED`** |
23+
24+
`SqlDriver` running the same sequence refuses in **both** cases: it has kept a
25+
sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner
26+
`computeTenantField` and not the wrapper that consults the record, so "mirrors
27+
`computeTenantField` arm for arm" stayed literally true while the pair diverged.
28+
29+
It is silent in both directions — nothing logs the flip, and the refusal names
30+
the field, never the partition. That is the declared-vs-enforced shape Prime
31+
Directive #10 forbids, reached by a state change rather than by a missing check.
32+
33+
## What it does now
34+
35+
- **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the
36+
sticky resolver, and the `TenantOptOutRecord` type for the per-instance record
37+
a driver owns. `InMemoryDriver` holds one and resolves through it, handing
38+
BOTH declaration surfaces — field-level `unique` and declared `indexes[]`
39+
the same resolved column. `uniqueConstraintsFromFields` and
40+
`uniqueConstraintsFromDeclaredIndexes` accept that column as an optional
41+
second argument; called with one argument they answer exactly as before.
42+
`tenantFieldOf` is unchanged and still a pure function of its argument.
43+
- **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with
44+
the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy`
45+
block gave a shard an organization key part the base table's index does not
46+
have — one object, two partitions, decided by which physical table a row
47+
landed in. It now resolves through the record, keyed by the base table.
48+
- **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The
49+
Archiver hands that object straight to `cold.syncSchema`, and the published
50+
type refused the key while the driver below read it — so an author writing a
51+
fresh literal was pushed into producing exactly the partial re-registration
52+
above. Same correction #16711 made where the shard leaf narrowed the key off
53+
the object it was handed.
54+
55+
The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a
56+
declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object
57+
that never declared the opt-out never enters the record, so a genuinely
58+
org-scoped object keeps its `organization_id` partition across a partial
59+
re-registration — an implementation answering `null` more often would not be
60+
stickier, it would be tenant isolation switched off. A carried `tenancy` block
61+
stays authoritative in both directions and CLEARS a recorded opt-out.
62+
63+
`@objectstack/driver-memory` is `minor` for the two new public-entry exports.
64+
The behaviour repairs themselves are `patch`: each restores an implementation to
65+
the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was
66+
already declaring, rather than replacing one legal published answer with
67+
another. The `objectql` entry is a published type WIDENING — a key the interface
68+
refused is now accepted, and nothing that compiled before stops compiling.

0 commit comments

Comments
 (0)