Skip to content

Commit ef5c3e5

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16582-invite-role-default-member
2 parents b99a4b6 + 470746a commit ef5c3e5

59 files changed

Lines changed: 5488 additions & 295 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: 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: 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.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
'@objectstack/types': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/metadata': patch
5+
'@objectstack/cli': patch
6+
'@objectstack/driver-sql': patch
7+
---
8+
9+
fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal
10+
11+
Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer
12+
lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a
13+
COMPOSED message that discloses neither the statement nor the diagnostic, and carries
14+
the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and
15+
is unchanged here.
16+
17+
What changed underneath it is what every consumer STORED. Each migration probe, backfill
18+
and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded
19+
`error.message` into an operator-facing record, so those records began reading
20+
21+
the database refused to run a raw statement
22+
23+
where they used to read
24+
25+
no such column: foo
26+
27+
For a live console that costs nothing — the driver prints the statement and the dialect
28+
text to its warn sink one line earlier. For a record read later it costs everything:
29+
whoever opens a customer install's backfill result a week on never had that line, and the
30+
dialect's words were unrecoverable for them.
31+
32+
`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk
33+
of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen
34+
stored-record sites plus `os db clean`'s console line read through it:
35+
36+
- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out;
37+
- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the
38+
three per-object warnings;
39+
- `partial-index-probe` — the `detail` both callers report (and its two module comments,
40+
which stated the opposite of what happened);
41+
- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`,
42+
`migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`;
43+
- `os db clean` — the `VACUUM failed` line.
44+
45+
Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned
46+
on its own message channel, its `cause` never walked, and a declared envelope that is not
47+
the raw-path one — the typed read exits' terminal, which composes a different sentence —
48+
is left exactly as it arrived.
49+
50+
That message channel is deliberately NOT byte-identical to what the replaced expressions
51+
computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as
52+
`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the
53+
string itself when a string was thrown, and `String(error)` when neither yields text. Every
54+
difference from the replaced expressions follows from that rule, so read the rule and not a
55+
list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`,
56+
which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a
57+
thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read
58+
`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no
59+
record was written at all and the operation aborted; an object carrying a NON-EMPTY string
60+
`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]`
61+
(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads
62+
`''`, so this channel is neither always prose nor never empty.
63+
64+
## The levels, and why they are not uniform
65+
66+
`@objectstack/types` takes **`minor`**: it is the one package here that grows a published
67+
surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the
68+
export list. A purely additive widening takes at least `minor`.
69+
70+
The other four take **`patch`**, because none of them widens anything: they are a bug fix in a
71+
released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named
72+
because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper
73+
against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this
74+
PR: no entry point reaches a test file, and `files` packs `dist` only.
75+
76+
**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter:
77+
what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name
78+
and never a type. The change these sites were made for is the declared raw-path fault, where
79+
the record gains the dialect's words in place of the driver's composed placeholder. Every
80+
other throw now reaches these records through the rule above rather than through the
81+
expression each site spelled out, so its text can move too — a consequence of the rule, not a
82+
bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record,
83+
and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an
84+
`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is
85+
`seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on
86+
top of the rule, so those same three shapes record `'unknown error'` there rather than `''`;
87+
that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and
88+
whether it should go is tracked by #17167. The sentence being replaced is not a value any
89+
consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these
90+
records gets the dialect's words back where it had been getting a placeholder.

0 commit comments

Comments
 (0)