Skip to content

Commit a8d2fd7

Browse files
feat(runtime,metadata-protocol,client,console): Studio publish takes a change note; item history names the person (#28422)
domain:ui#5 · os-sales · session_01L6bhgUAnBEBqJCebezxh8P <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuTDNmH1VTVNpot2Ap6Ado7u)_ Part of #28406 — items 1, 2, 3 and 5. Item 4 (the publish-drafts request schema in `packages/spec`, route ledger, OpenAPI) stays on the card for the spec lane, per the split note on the card. Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 发布时可以写一句变更说明,它会写进这次发布的每一条元数据历史;历史面板显示操作者的名字(查不到时显示 id 并标注"未解析的用户"),说明显示在该行下面。 - 为什么改:历史里只有一串用户 id 和一句固定的 `publish app package 'PACKAGE_ID'`,管理员挑版本回滚时看不出是谁、为什么改的;另外发布门把请求体里的 `actor` 当作操作者,持有 `manage_metadata` 的人可以冒名。 - 风险与回滚:发布门现在只认会话身份,请求体 `actor` 被忽略;不写说明时行为与之前一致。回滚即还原本 PR,没有数据迁移。 - 先看:`packages/runtime/src/domains/packages.ts`(publish-drafts 门取会话身份、转发 message)、`packages/metadata-protocol/src/protocol.ts`(每项 promote 的 change_note)、`ui/packages/app-shell/src/views/item-tools/historyActors.ts`(姓名解析)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-publish-drafts-change-note.integration.test.ts`,预期 4 个用例全绿(真实 SQLite 里每条历史行带说明、记录会话用户)。 - 需要你做的:无,席位落地。 ## What changes **Runtime door (`POST /packages/:id/publish-drafts` only).** The door forwards a string `message` from the body, and records the actor as the user id of the request's authorized execution context (`resolvePackageWriteActor`, the rule `RestServer.resolveMetaWriteActor` holds for the `/meta` write doors). A body `actor` is ignored; a context with no user id records no actor. The six sibling package doors are untouched (carried by #28414). **Protocol.** `publishPackageDrafts` passes `request.message` to each `promoteDraftForPublish` in place of the hard-coded `publish app package 'PACKAGE_ID'`, so every `sys_metadata_history` row the batch writes stores it as `change_note`. The package commit already kept it. No note, or an empty one, keeps today's sentence. **SDK.** `packages.publishDrafts(id, opts)` gains `opts.message`. `opts.actor` stays in the type, marked `@deprecated` (the door ignores it), so no caller breaks. **Console.** - `MetadataClient.publishPackageDrafts` picks `message` beside `backfill` and sends it only when given (no options still sends `{}`). - The publish review (`DraftChangesPanel` footer) offers an optional change note where it publishes one package as a batch (`onPublish && packageId`, the same condition as the backfill choice). Sent trimmed; an empty note sends nothing, and a sheet with neither a note nor a backfill choice still calls its host with no options. `StudioDesignSurface.tsx` is not edited: its `doPublish(options)` already hands the sheet's options to `publishPackageDrafts(packageId, options)`. - `HistoryPanel` shows each actor by name, read from `sys_user` once per set of ids (`historyActors.ts`). An id with no row (a deleted user), a failed or refused directory read, or no adapter shows the id marked "unresolved user". A `null` actor shows none. The event's `message` is shown under the row; `HistoryEvent` gains `message`. - Strings: `engine.edit.historyBy` / `historyActorUnresolved` / `historyActorUnresolvedHint` in the studio namespace (en, zh); `preview.changes.noteLabel` / `notePlaceholder` in all ten locale packs. Branch: the harness-assigned `claude/project-thread-64y985`, not `claude/issue-28406-*`; the card is #28406. ## Tests - `packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts` (new, real ObjectQL + SqlDriver over better-sqlite3, through `HttpDispatcher.handlePackages`): a note reaches every history row and the commit, and `historyMetaItem` reads it back as `message` with the session user as `actor`; no note keeps the sentence; a body `actor` is ignored and the session user is recorded on every row and the commit; a system context records no actor. - `ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.changeNote.test.tsx` (new): the field is offered only on the batch door with a Publish, disabled while publishing; a note is sent trimmed as `{ message }`; empty or whitespace sends no options; the note rides beside a checked backfill choice. - `ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.actorAndNote.test.tsx` (new): name shown with the id on its title and one directory read for the distinct ids; a missing row and a failed read show the id marked unresolved; a `null` actor shows none; notes under rows. - `ui/packages/data-objectstack/src/metadata-client.publishMessage.test.ts` (new): the body bytes with and without a note. - Moved pin: `packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts` asserted the exact protocol request `{ packageId, context }`; it now carries `actor: the caller's userId`, the change this PR makes. ## Acceptance notes - `message` follows the single-item publish door's rule: a string is forwarded, any other type sends no note (no 400). Item 4's request schema is where a stricter shape would be declared. - The unresolved marker does not say "deleted": a missing row and a refused directory read look the same from the console, and naming the wrong one would be a made-up fact on an audit surface. The hint text names both causes. - The claim's `Metadata-protocol: yes` is outside the closed vocabulary the changeset gate reads (`added | narrowed | semantic | none`). Nothing a metadata author writes changes here (a REST body key, a history note, the console), so this PR declares `none`, and the changeset is `patch`. - The claim named `ui/packages/i18n/src/locales/{en,zh}.ts`; the publish-review keys live in the locale packs, which `all-locales-key-parity.test.ts` holds to every `en` key, so all ten packs carry them. The history panel's keys live in the studio namespace, which is en + zh by design. - `usePublishAllDrafts.ts` (Home banner, draft-preview bar) is unchanged: neither surface has a review step with a note, and a parameter no caller passes would be declared but never written. ## Verification Local readings. Tests and ablations ran on `90af062b` (the fix and its tests); the merge of `origin/main` (`dc581177`) brought in no file of the six packages touched here. Gates ran on `dc581177`; the changeset gates were re-run on `db993774` after a wording-only changeset edit. The head, `5d035cf3`, adds one string-only commit on top (full-width punctuation in the zh `engine.edit.historyActorUnresolvedHint`); `@object-ui/i18n` `tsc --noEmit` exit 0 and `check-nul-bytes` OK on it. <details><summary>Tests (all green)</summary> - `@objectstack/runtime`: the 52 package-door and dispatcher files (`src/domains/packages*.test.ts`, `src/package-*.integration.test.ts`, `src/http-dispatcher.test.ts`, `src/dispatcher-validation-error*.test.ts`): Test Files 52 passed, Tests 1106 passed, after the one moved pin above (it was the one red: `expected … to deeply equal`, the new `actor` key). - `@objectstack/objectql` publish and commit suites (7 files): 94 passed. - `@objectstack/metadata-protocol` whole package: Test Files 285 passed / 4 skipped, Tests 21872 passed / 26 skipped. - Console, from `ui/` root: the DraftChangesPanel suites, `views/item-tools`, the two existing HistoryPanel suites, `usePublishAllDrafts` suites and the metadata-client publish suites: Test Files 41 passed, Tests 257 passed. - Typecheck, each exit 0: `@objectstack/metadata-protocol`, `@objectstack/client`, `@objectstack/runtime` (with its test layer), `@object-ui/i18n`, `@object-ui/data-objectstack`, `@object-ui/app-shell` (`tsc --noEmit` and `tsconfig.test.json`). </details> <details><summary>Ablations (each leg mutated through `scripts/ablation-replace.mjs`, restored blob == HEAD)</summary> - Protocol: the per-item note forced to the default (`? request.message` replaced by a `globalThis` marker), `metadata-protocol` rebuilt, marker proved in `dist/` by `ablation-dist-preflight` → the integration file 1 failed / 3 passed (the note test). Restored, rebuilt, marker proved absent → 4/4. A first attempt with the anchor `message: request.message` was refused as ambiguous (4 hits); nothing was written. - Door: the actor read back from `body?.actor` → 3 failed / 1 passed (note-with-actor, body actor ignored, system context). - Publish review: the `message` key never sent → 2 failed / 5 passed. - History panel: names never passed to the row → 3 failed / 1 passed; the note never drawn → 1 failed / 3 passed. </details> <details><summary>Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 67 families)</summary> - 65 exit 0 on `dc581177`. - `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads`, each exit 0 on `db993774`. Their first run exited 3 (prerequisite, not a verdict: no generated schema and 34 packages without `dist/` on a fresh clone); re-run after `gen:schema` and a full `turbo run build` (117 tasks, exit 0). `--ran` reconcile: 67/67, 0 NOT-MEASURED. - Re-run on `db993774`: `check-changeset-no-major`, `check-empty-changeset`, `check-adr-0087-registration` (all `--base origin/main`) and `check:doc-authoring`, each exit 0. - Dogfood battery: not owed (no `!` in the title, no `(narrowing)` arm). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUAnBEBqJCebezxh8P)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4723519 commit a8d2fd7

25 files changed

Lines changed: 819 additions & 23 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/client': patch
5+
'@object-ui/app-shell': patch
6+
'@object-ui/data-objectstack': patch
7+
'@object-ui/i18n': patch
8+
---
9+
10+
feat(runtime,metadata-protocol,client,console): Studio's publish takes a change note, every history row it writes carries it, and the item history names the person who changed it
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: none
14+
15+
**The note reaches each item's history.** `POST /packages/:id/publish-drafts` now forwards a string `message` from its body. `publishPackageDrafts` already wrote that note into the package commit; it now also passes it to each item's promote, so every `sys_metadata_history` row the publish writes stores it as `change_note` (the history read returns it as each event's `message`). A publish with no note, or an empty one, keeps the sentence the rows carried before, `publish app package 'PACKAGE_ID'`. The SDK's `packages.publishDrafts(id, opts)` accepts `opts.message`, and the console's `MetadataClient.publishPackageDrafts` sends it only when given, so a caller that names no note sends the same `{}` body as before.
16+
17+
**Who published is the signed-in caller.** The door used to record `body.actor` as the publisher, so a caller holding `manage_metadata` could put any name on every history row and on the commit. It now records the user id of the request's authorized execution context, the rule the single-item `/meta` publish door follows, and ignores a body `actor`. A caller with no user id (a system context) records no actor (`NULL`), never the body's. The SDK keeps `opts.actor` in its type, marked deprecated, so existing callers still compile; the door ignores it. The other package write doors (discard, revert, rollback and the rest) are unchanged here.
18+
19+
**In Studio.** The publish review (the pending-changes sheet's footer) offers an optional **Change note** where it publishes one package as a batch; the note is sent trimmed, and an empty one sends nothing. The item history panel shows each event's actor by the user's name, read from `sys_user` once per set of ids. An id with no user row behind it (a deleted user), or a directory the viewer cannot read, is shown as the id marked "unresolved user". A write no person made shows no actor. Each event's change note is shown under its row. English and Chinese strings are added to the console i18n bundles (the publish-review strings in all ten locale packs, as the key-parity suite requires).

‎packages/client/src/index.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3018,10 +3018,23 @@ export class ObjectStackClient {
30183018
* signed-in caller; `backfillApplied` reports the rows written per field.
30193019
* A field that is not new, or declares no literal default, refuses the
30203020
* whole request with a 400 before anything is published.
3021+
*
3022+
* `opts.message` (ADR-0146 D1) is the author's change note: the package
3023+
* commit records it, and so does every item history row this publish
3024+
* writes (`change_note`, read back as each event's `message` by `meta.getHistory`).
3025+
* Without one those rows carry the sentence naming the package.
3026+
*
3027+
* The publish is recorded as the signed-in caller. `opts.actor` is
3028+
* ignored by this door and kept only so existing callers still compile.
30213029
*/
30223030
publishDrafts: async (
30233031
id: string,
3024-
opts?: { actor?: string; backfill?: Array<{ object: string; field: string }> },
3032+
opts?: {
3033+
/** @deprecated Ignored: the door records the signed-in caller (ADR-0146 D1). */
3034+
actor?: string;
3035+
backfill?: Array<{ object: string; field: string }>;
3036+
message?: string;
3037+
},
30253038
): Promise<PublishPackageDraftsResponse> => {
30263039
const route = this.getRoute('packages');
30273040
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish-drafts`, {

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23781,7 +23781,13 @@ export class ObjectStackProtocolImplementation implements
2378123781
async publishPackageDrafts(request: {
2378223782
packageId: string;
2378323783
actor?: string;
23784-
/** ADR-0067 — commit message (for AI turns: the user's instruction). */
23784+
/**
23785+
* ADR-0067 — commit message (for AI turns: the user's instruction).
23786+
* [ADR-0146 D1] Also the change note of every item this publish
23787+
* promotes: each `sys_metadata_history` row it writes records it as
23788+
* `change_note`. Absent or empty, those rows keep the sentence naming
23789+
* the package.
23790+
*/
2378523791
message?: string;
2378623792
/** ADR-0067 — AI model that authored the turn (absent for human/CLI). */
2378723793
aiModel?: string;
@@ -24352,7 +24358,15 @@ export class ObjectStackProtocolImplementation implements
2435224358
// an object draft beside them may name.
2435324359
batchDatasources,
2435424360
...(request.actor ? { actor: request.actor } : {}),
24355-
message: `publish app package '${request.packageId}'`,
24361+
// [ADR-0146 D1] The author's change note, when the
24362+
// publish carries one, is what every history row
24363+
// this batch writes records as its `change_note`
24364+
// (the same note the package commit below keeps),
24365+
// so the item history says why the item changed.
24366+
// With none, the sentence naming the package.
24367+
message: request.message
24368+
? request.message
24369+
: `publish app package '${request.packageId}'`,
2435624370
});
2435724371
promoted.push({
2435824372
d, singularType,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0146 D1 — `POST /packages/:id/publish-drafts` carries the author's change
5+
* note into every item history row it writes, and records WHO published as the
6+
* identity the request was authorized as.
7+
*
8+
* Before this, the door forwarded `body.actor` and never `message`; the
9+
* protocol wrote the note only into the package commit and hard-coded every
10+
* history row's `change_note` as the sentence naming the package. So Studio's
11+
* item history could not say why an item changed, and any caller holding
12+
* `manage_metadata` could record any name as the publisher.
13+
*
14+
* What is real: the door (`HttpDispatcher.handlePackages`), the protocol's
15+
* `publishPackageDrafts` and its per-item promote, ObjectQL, and a SqlDriver
16+
* over on-disk better-sqlite3 holding the four metadata tables. The rows read
17+
* back are what landed in SQLite, not what a double was told.
18+
*/
19+
20+
import { afterEach, describe, expect, it } from 'vitest';
21+
import { mkdtempSync, rmSync } from 'node:fs';
22+
import { tmpdir } from 'node:os';
23+
import { join } from 'node:path';
24+
import { ObjectQL } from '@objectstack/objectql';
25+
import { SqlDriver } from '@objectstack/driver-sql';
26+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
27+
import {
28+
SysMetadataObject,
29+
SysMetadataHistoryObject,
30+
SysMetadataAuditObject,
31+
SysMetadataCommitObject,
32+
} from '@objectstack/metadata-core';
33+
import { captureExpectedReadRefusals, type ExpectedReadRefusalCapture } from '../expected-read-refusal-noise.js';
34+
import { HttpDispatcher } from '../http-dispatcher.js';
35+
36+
const PKG = 'com.notes.app';
37+
const PLATFORM_PKG = '@objectstack/platform-objects';
38+
/** The object the published views bind to; the build probe reads it (see the list-commits suite). */
39+
const UNBOUND_PROBE_OBJECT = 'anything';
40+
const DEFAULT_NOTE = `publish app package '${PKG}'`;
41+
42+
let cleanup: Array<() => void> = [];
43+
let noise: ExpectedReadRefusalCapture | null = null;
44+
afterEach(() => {
45+
for (const c of cleanup) c();
46+
cleanup = [];
47+
expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]);
48+
noise = null;
49+
});
50+
51+
async function boot() {
52+
const dir = mkdtempSync(join(tmpdir(), 'os-publish-note-'));
53+
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
54+
const driver = new SqlDriver({
55+
client: 'better-sqlite3',
56+
connection: { filename: join(dir, 'data.sqlite') },
57+
useNullAsDefault: true,
58+
});
59+
const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject] as any[];
60+
noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]);
61+
noise.captureDriver(driver);
62+
await driver.initObjects(objects);
63+
64+
const engine = new ObjectQL();
65+
noise.captureEngine(engine);
66+
engine.registerDriver(driver as any, true);
67+
await engine.init();
68+
for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG);
69+
engine.registry.installPackage({ id: PKG, name: PKG, version: '1.0.0' } as any, 'authored');
70+
cleanup.push(() => { void engine.destroy(); });
71+
72+
const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, undefined, 'package-author');
73+
const kernel: any = {
74+
getService: (name: string) => {
75+
if (name === 'protocol') return Promise.resolve(protocol);
76+
if (name === 'objectql') return Promise.resolve(engine);
77+
return null;
78+
},
79+
context: { getService: () => null, trigger: () => {} },
80+
};
81+
return { engine, protocol, dispatcher: new HttpDispatcher(kernel) };
82+
}
83+
84+
async function draftViews(protocol: any, names: string[]) {
85+
for (const name of names) {
86+
await protocol.saveMetaItem({
87+
type: 'view',
88+
name,
89+
item: {
90+
name,
91+
label: name,
92+
type: 'grid',
93+
object: UNBOUND_PROBE_OBJECT,
94+
viewKind: 'list',
95+
data: { provider: 'object', object: UNBOUND_PROBE_OBJECT },
96+
columns: ['id'],
97+
},
98+
packageId: PKG,
99+
mode: 'draft',
100+
});
101+
}
102+
}
103+
104+
const caller = (userId: string) => ({
105+
request: {},
106+
executionContext: { userId, systemPermissions: ['manage_metadata', 'studio.access'] },
107+
}) as any;
108+
109+
/** Publish through the door and return the history rows and commit rows this publish wrote. */
110+
async function publish(env: Awaited<ReturnType<typeof boot>>, body: Record<string, unknown>, userId = 'u_alice') {
111+
const seqBefore = Math.max(
112+
0,
113+
...((await env.engine.find('sys_metadata_history', { where: {} })) as any[]).map((r) => Number(r.event_seq)),
114+
);
115+
const result = await env.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, caller(userId));
116+
expect(result.response?.status).toBe(200);
117+
const data: any = (result.response as any)?.body?.data;
118+
expect(data?.outcome).toBe('published');
119+
const history = ((await env.engine.find('sys_metadata_history', { where: {} })) as any[])
120+
.filter((r) => Number(r.event_seq) > seqBefore);
121+
const commits = (await env.engine.find('sys_metadata_commit', { where: { package_id: PKG } })) as any[];
122+
return { data, history, commit: commits.find((c) => c.id === data.commitId) };
123+
}
124+
125+
describe('publish-drafts records the change note and the session user (ADR-0146 D1)', () => {
126+
it('a note reaches every history row the publish writes, and the package commit', async () => {
127+
const env = await boot();
128+
await draftViews(env.protocol, ['notes_open', 'notes_closed']);
129+
130+
const { data, history, commit } = await publish(env, { message: 'Split notes into open and closed lists' });
131+
132+
expect(data.publishedCount).toBe(2);
133+
expect(history.map((r) => r.name).sort()).toEqual(['notes_closed', 'notes_open']);
134+
for (const row of history) expect(row.change_note).toBe('Split notes into open and closed lists');
135+
expect(commit?.message).toBe('Split notes into open and closed lists');
136+
// And read back through the history door's own mapping, as the panel reads it.
137+
const read: any = await env.protocol.historyMetaItem({ type: 'view', name: 'notes_open' });
138+
const latest = [...read.events].sort((a: any, b: any) => b.seq - a.seq)[0];
139+
expect(latest).toMatchObject({ op: 'publish', message: 'Split notes into open and closed lists', actor: 'u_alice' });
140+
});
141+
142+
it('no note keeps the sentence naming the package on every row', async () => {
143+
const env = await boot();
144+
await draftViews(env.protocol, ['notes_open', 'notes_closed']);
145+
146+
const { history } = await publish(env, {});
147+
148+
expect(history).toHaveLength(2);
149+
for (const row of history) expect(row.change_note).toBe(DEFAULT_NOTE);
150+
});
151+
152+
it('a body `actor` is ignored: the authorized user is recorded on every row and on the commit', async () => {
153+
const env = await boot();
154+
await draftViews(env.protocol, ['notes_open', 'notes_closed']);
155+
156+
const { history, commit } = await publish(env, { actor: 'u_mallory', message: 'n' }, 'u_alice');
157+
158+
expect(history).toHaveLength(2);
159+
for (const row of history) expect(row.recorded_by).toBe('u_alice');
160+
expect(commit?.actor).toBe('u_alice');
161+
});
162+
163+
it('a caller with no user id records no actor, never the body’s', async () => {
164+
const env = await boot();
165+
await draftViews(env.protocol, ['notes_open']);
166+
const system = { request: {}, executionContext: { isSystem: true } } as any;
167+
168+
const result = await env.dispatcher.handlePackages(
169+
`/${PKG}/publish-drafts`, 'POST', { actor: 'u_mallory' }, {}, system,
170+
);
171+
172+
expect(result.response?.status).toBe(200);
173+
const rows = (await env.engine.find('sys_metadata_history', { where: { name: 'notes_open' } })) as any[];
174+
const published = rows.filter((r) => r.operation_type === 'publish');
175+
expect(published).toHaveLength(1);
176+
expect(published[0].recorded_by ?? null).toBeNull();
177+
});
178+
});

‎packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,9 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => {
404404
// caller's execution context does ride along, verbatim: the build
405405
// probes read a widget's dataset AS that caller (the renderer's path),
406406
// and nothing in the publish derives a write scope from it.
407+
// [ADR-0146 D1] Its user id is the recorded actor; no organization is.
407408
const caller = PKG_ADMIN(ORG).executionContext;
408-
expect(publishRequest()).toEqual({ packageId: PKG, context: caller });
409+
expect(publishRequest()).toEqual({ packageId: PKG, actor: caller.userId, context: caller });
409410
expect(publishRequest()).not.toHaveProperty('organizationId');
410411
});
411412
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,25 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon
343343
return null;
344344
}
345345

346+
/**
347+
* The actor a package write door records (ADR-0146 D1): the user id of the
348+
* execution context this request was authorized as — the same context
349+
* {@link requireManageMetadata} judged a few lines earlier — or `undefined`
350+
* when the caller carries none, so the protocol's no-actor default (a `NULL`
351+
* `recorded_by`) applies and a machine write is never stamped with a person.
352+
*
353+
* Never the request body: a body `actor` let any caller holding
354+
* `manage_metadata` record any name as the one who changed a package. This is
355+
* the rule `RestServer.resolveMetaWriteActor` holds for the `/meta` write
356+
* doors, so attribution cannot drift from authorization. Only
357+
* `publish-drafts` reads it today; the sibling package doors that still read
358+
* `body.actor` are carried by their own card.
359+
*/
360+
function resolvePackageWriteActor(context: HttpProtocolContext | undefined): string | undefined {
361+
const userId: unknown = (context?.executionContext as { userId?: unknown } | undefined)?.userId;
362+
return typeof userId === 'string' && userId ? userId : undefined;
363+
}
364+
346365
/**
347366
* ADR-0106 D4 READ gate for the `/packages` domain (#7033 / #7023).
348367
*
@@ -1886,9 +1905,18 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
18861905
return { handled: true, response: deps.error(`Package '${id}' not found`, 404) };
18871906
}
18881907
try {
1908+
// [ADR-0146 D1] Who published: the identity this request
1909+
// was authorized as, never a name the body supplies — see
1910+
// `resolvePackageWriteActor`. A body `actor` is ignored.
1911+
const actor = resolvePackageWriteActor(_context);
18891912
const result = await protocol.publishPackageDrafts({
18901913
packageId: id,
1891-
...(body?.actor ? { actor: body.actor } : {}),
1914+
...(actor !== undefined ? { actor } : {}),
1915+
// [ADR-0146 D1] The author's change note: the package
1916+
// commit and every item history row this publish writes
1917+
// carry it. A string, as the single-item publish door
1918+
// reads `message`; anything else sends no note.
1919+
...(typeof body?.message === 'string' ? { message: body.message } : {}),
18921920
// [#24253] Passed through as sent: the protocol judges
18931921
// its shape and what it names, and refuses the whole
18941922
// request before anything is published.

0 commit comments

Comments
 (0)