@@ -7354,12 +7354,61 @@ export class ObjectStackProtocolImplementation implements
73547354 * AFTER `put()` resolves successfully, so a failed write — DB error,
73557355 * optimistic-lock conflict, validation failure — never leaks a
73567356 * stale schema into the registry.
7357+ *
7358+ * ── OWNERSHIP KEY (#4636, maintainer ruling 2026-08-07, option B) ──
7359+ *
7360+ * The contributor is keyed by the row's REAL `package_id`, not by the
7361+ * `'sys_metadata'` sentinel this used to hard-write. The sentinel
7362+ * survives for exactly one case — a package-less write, which has no
7363+ * real id to use.
7364+ *
7365+ * Why the key had to move (and not the other side): the ownership key
7366+ * IS the package-filter key. `SchemaRegistry.getAllObjects(packageId)`
7367+ * matches `contributor.packageId`, so an object created through
7368+ * Studio's package workspace was invisible to its own package's filter
7369+ * until something re-registered it. The written contract in
7370+ * `objectql/src/registry.ts` (the `isTenantAuthored` header) already
7371+ * said the real id is the key and the sentinel is a save-path-only
7372+ * artefact; this makes the save path say the same thing.
7373+ *
7374+ * ── `_provenance: 'org'` IS STAMPED HERE, SERVER-SIDE ──
7375+ *
7376+ * On a COPY of the body, unconditionally — the request's own
7377+ * `_provenance` is never consulted, and never wins.
7378+ *
7379+ * That is load-bearing, not defensive coding. `applyProtection` (spec)
7380+ * stamps `_provenance: 'package'` whenever it is handed a package id
7381+ * and the body has not already answered, and the registry's artifact
7382+ * lookup reads exactly that key: a row registered under `app.<slug>`
7383+ * with package provenance IS a code artifact as far as
7384+ * `getArtifactItem` is concerned, so `isArtifactBacked` turns true and
7385+ * `saveMetaItem`'s overlay gate refuses the NEXT write to it with
7386+ * `not_overridable` — `object` declares `allowOrgOverride: false`.
7387+ * Moving the key without the stamp therefore re-creates cloud#970 (an
7388+ * app the user just built becomes silently un-editable) on the write
7389+ * path, one save later instead of one restart later. Measured, not
7390+ * assumed: see the reverse-verification limb in
7391+ * `objectql/src/protocol-writepath-object-ownership.test.ts`.
7392+ *
7393+ * Client-supplied provenance cannot be trusted here:
7394+ * `metadata-read-decorations.ts` deliberately does NOT strip
7395+ * `_provenance`, so a Studio GET → PUT round-trip echoes whatever the
7396+ * served document carried. Every row this method sees came out of a
7397+ * `sys_metadata` write, which is tenant-authored by definition
7398+ * (ADR-0010 `_provenance: 'org'`) — so the server states that fact
7399+ * rather than reading it back from the caller. Same sentence the boot
7400+ * re-hydration already writes for the same rows.
73577401 */
7358- private applyObjectRegistryMutation(request: { type: string; name: string; item?: any }): void {
7402+ private applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): void {
73597403 if (request.type !== 'object' && request.type !== 'objects') return;
73607404 this.engine.registry.registerItem(request.type, request.item, 'name');
73617405 try {
7362- this.engine.registry.registerObject(request.item as any, 'sys_metadata');
7406+ this.engine.registry.registerObject(
7407+ { ...(request.item as Record<string, unknown>), _provenance: 'org' } as any,
7408+ // `||`, not `??`: an empty-string binding is "no package", the
7409+ // same normalisation the boot branch applies to `package_id`.
7410+ request.packageId || 'sys_metadata',
7411+ );
73637412 } catch (err: any) {
73647413 console.warn(
73657414 `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`,
@@ -7526,6 +7575,43 @@ export class ObjectStackProtocolImplementation implements
75267575 }
75277576 }
75287577
7578+ /**
7579+ * [#4636] The package binding of a persisted overlay row, read from the
7580+ * row itself.
7581+ *
7582+ * The write paths that HAVE a `packageId` parameter (`saveMetaItem`, the
7583+ * publish promotion) pass the caller's binding straight through — the same
7584+ * value `SysMetadataRepository.put` stamps on the row, so key and row agree
7585+ * by construction. `rollbackMetaItem` has no such parameter: it addresses a
7586+ * row that already exists, and the row's own `package_id` is the only
7587+ * authoritative answer to "who owns this".
7588+ *
7589+ * Mirrors the repository's own `whereFor(ref, 'active', undefined)`: no
7590+ * package predicate (match any package), scoped by org + type + name +
7591+ * state. Deliberately NOT a `findOne` on the repository — `MetadataItem`
7592+ * projects the body, not the binding, and widening that shared type to
7593+ * carry one field for one caller is a contract change PR1 does not need.
7594+ *
7595+ * Not caught: a metadata-store outage here means the ownership key would be
7596+ * a guess, and every caller reads this BEFORE its write, so failing is
7597+ * still failing closed.
7598+ */
7599+ private async resolveOverlayPackageBinding(
7600+ type: string,
7601+ name: string,
7602+ organizationId: string | null,
7603+ ): Promise<string | null> {
7604+ const row = await this.engine.findOne('sys_metadata', {
7605+ where: {
7606+ type,
7607+ name,
7608+ organization_id: organizationId,
7609+ state: 'active',
7610+ },
7611+ });
7612+ return (row as { package_id?: string | null } | null)?.package_id ?? null;
7613+ }
7614+
75297615 /**
75307616 * Inverse of {@link ensureObjectStorage}: drop an object's physical table.
75317617 * DESTRUCTIVE — deletes the table and all its rows. Only invoked when a
@@ -10137,6 +10223,19 @@ export class ObjectStackProtocolImplementation implements
1013710223 name: request.name,
1013810224 org: orgId ?? 'env',
1013910225 } as Parameters<typeof repo.restoreVersion>[0];
10226+ // [#4636] The ownership key the write-through below needs, read from
10227+ // the ROW rather than from the request — `rollbackMetaItem` has no
10228+ // `packageId` parameter, and inventing one would let a caller re-key
10229+ // an object it does not own. `restoreVersion` → `put` preserves an
10230+ // existing non-null `package_id` on update, so the binding read here
10231+ // is the binding the restored row still carries.
10232+ //
10233+ // Read BEFORE the restore, deliberately: the row exists at this point
10234+ // and a read failure can still fail the whole rollback cleanly. Reading
10235+ // it afterwards would put a fallible query downstream of a write that
10236+ // already succeeded — the shape that ends in a `catch {}` swallowing a
10237+ // real outage (#4867).
10238+ const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId);
1014010239 try {
1014110240 const result = await repo.restoreVersion(ref, request.toVersion, {
1014210241 // #4556 — NULL, not 'system', for an actor-less rollback.
@@ -10148,10 +10247,17 @@ export class ObjectStackProtocolImplementation implements
1014810247 // #4521 — a rollback is a live write like any other: the restored
1014910248 // body must be the one the runtime dispatches on immediately, not
1015010249 // after someone lists the type.
10250+ // #4636 — …under the SAME ownership key `saveMetaItem` used. Left
10251+ // unpassed, an object row bound to `app.<slug>` re-registered here
10252+ // under the `'sys_metadata'` sentinel and `registerObject` threw
10253+ // `already owned by package "app.<slug>"` into the best-effort
10254+ // `console.warn` — a rollback that reported success while the
10255+ // registry kept serving the body it was supposed to revert.
1015110256 this.applyRegistryWriteThrough({
1015210257 type: singularType,
1015310258 name: request.name,
1015410259 item: result.item.body,
10260+ packageId: rollbackPackageId,
1015510261 });
1015610262 return {
1015710263 success: true,
0 commit comments