Skip to content

Commit 448374d

Browse files
cloud-connection, plugin-security: a marketplace upgrade that would un-make an access choice is refused until it is confirmed (#24446 U2) (#25355)
Part of #24446 U2 of #24446: the marketplace refusal. It works to the measurement in 6355301005 and the seat's GO in 6355317159, under claim 6355237423. U1 (the boot report) landed as `54df640b38`. The ADR-0126 dated note is a separate Tier H docs PR that the seat cuts. #24446 remains open for it. Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening). Metadata-protocol: none ## What changed **`cloud-connection`: step 2c of `POST /api/v1/marketplace/install-local`.** It runs on a managed re-install over an installed package (`findConflict === 'marketplace'`), after the D5e gate and before the first write (`manifest.register`). A fresh install has no customer choice of its own to un-make, so it is never checked. The door asks the `security.accessUpgradeCheck` service as a capability question, so `cloud-connection` takes no runtime dependency on `plugin-security`. - **Findings:** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, and nothing is registered or written. - `error.details.findings` names each finding: its kind, type, name, the candidates and binding positions, the missing names, and a one-line `describe`. - `error.details.findingSet` is the token, and `error.details.confirmWith` is the retry. - **The confirmation:** the same request with `confirmAccessUpgrade: <findingSet>`. - The token confirms exactly the findings it names. If the findings change, it no longer matches and the call is refused again with the fresh set. - A bare `true` confirms nothing. - A confirmed install records `accessUpgradeConfirmation: { findingSet, confirmedBy, confirmedAt }` in its ledger entry. - **The check stopped** (the ledger, the catalog or the declared rules unreadable): `503 SERVICE_UNAVAILABLE`, and nothing is written. - **No check service** (no security plugin in the composition): the upgrade proceeds, and one `warn` line says no check ran. **`plugin-security`: `access-upgrade-check.ts` (new), registered by `SecurityPlugin` as `security.accessUpgradeCheck`.** It runs U1's `collectAccessUpgradeFindings` unchanged and restates none of its rules. It runs it twice: 1. over the registry as it stands; 2. over a projection of that registry with package P's items taken from the incoming manifest: - the catalog entries (permission sets, positions, capabilities) with `packageId === P`; - the declared sharing rules; - P's objects and fields: the registry's field set, less what P's previous version declared, plus the incoming declaration's. The customer's state (the ledger, the `sys_sharing_rule` rows) is never projected. The answer is the **delta**: what run 2 finds that run 1 does not, and a finding that only grew keeps its new parts only. So a finding that predates the upgrade, or that another package caused, never blocks it; U1's boot report keeps raising those. "P's previous version" is the door's ledger entry, or else the registry's own record of the package. Sources are resolved at every call. **`spec`: one error-code ledger row**, `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`, and the three generated reference pages that list it. **`cli`: `os package install … --confirm-access-upgrade <token>`.** It sends the token. On the refusal, it prints every finding's `describe` line and the exact retry. ## Who sees it - **Walled (`isolated`, `group`):** only the platform operator reaches step 2c. The existing ADR-0131 D12 refusal answers a tenant administrator `403` before the check runs, and shows no finding. - **`single`:** the administrator (`manage_metadata`). ## Limits, as in U1 (accepted by the seat, 6353222762) - A re-enablement is "suspected": nothing records which name replaced which. - The field-deny check reads "grants the same object", not "copied from". - "The old name carrying a clone" is carried by the switch-off half (ADR-0126 §9). - An object that the incoming manifest adds and the registry does not yet hold is judged on its declared fields only, without the fields the registry would inject. ## Scope held - Not `runtime/src/domains/packages.ts` (`POST /api/v1/packages` with `overwrite`). It is the seat's follow-up card, which calls this same service. - Not cloud's own install path. If it has one, it calls this service in cloud's follow-up. - Not `docs/adr/**`, not U1's boot report (imported read-only), and no `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` change. - `cloud-connection` gains `@objectstack/plugin-security` as a **devDependency** only, for its door test. There is no cycle: it already reaches plugin-security through `@objectstack/runtime`. ## Tests - **`cloud-connection/src/marketplace-install-local-access-upgrade.test.ts`** (34). The real door runs against the real `createAccessUpgradeCheck`, over an in-memory access state with version 1 installed through the door. For each of the four types (permission set, position, sharing rule, capability): - `single`: the administrator gets 409 with the finding; - `isolated` and `group`: the operator gets 409; - `isolated` and `group`: a tenant administrator gets 403 from the wall, the check is not called, and no finding is shown; - CONTROLs under `single` and `isolated` (200). - Plus: the token installs and is recorded; `true` and a stale token are refused; a finding that predates the upgrade does not block it; a stopped check gives 503 and writes nothing; no check service means 200 and a warning. - **`plugin-security/src/access-upgrade-check.test.ts`** (15). The check as `SecurityPlugin` registers it, over a real `ObjectQL` and SQL driver, for the four types with CONTROLs. It also covers a clone naming a field the upgrade removes (version 1 read from the registry), a field deny the managed set gains, the delta, the fail-closed stop, and "unbound before start". - **`cli/test/package-install-access-upgrade.test.ts`** (3): the flag, its absence, and the refusal's rendering. - **Ablations**, each red and then restored from the committed source: - disabling step 2c turns 21 door pins red; - refusing on the whole projected report instead of the delta turns the "predates" pin red in both suites; - turning off the catalog projection turns 4 of the check's pins red. The proof readings are in the os-dev-report on #24446: CI, the dispatch gates, `check-governed-merges --branch`, R1, R4 under `isolated` and `group`, the upgrade harness and the dogfood battery. --- _Generated by [Claude Code](https://claude.ai/code/session_01U9Aia8cnVwrpSUL9KgVzdu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d12e650 commit 448374d

18 files changed

Lines changed: 1472 additions & 9 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/spec': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3)
9+
10+
Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening).
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. -->
14+
15+
**BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty.
16+
17+
- **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those.
18+
- **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off.
19+
- **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry.
20+
- **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands.
21+
- **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`).
22+
- **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran.
23+
- **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before.
24+
25+
The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2982,6 +2982,7 @@ credentials do not apply here.
29822982
| `--email <email>` | `OS_RUNTIME_EMAIL` | Account email on the target runtime |
29832983
| `--password <password>` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime |
29842984
| `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below |
2985+
| `--confirm-access-upgrade <token>` | — | Confirm an upgrade's access findings with the finding-set token the runtime's refusal printed — see below |
29852986
| `--timeout <ms>` | `OS_CLOUD_TIMEOUT_MS` | HTTP timeout in milliseconds, default `120000`. `0` disables it |
29862987

29872988
**`--confirm-global-uniques` answers a stop; it does not force one past.**
@@ -2997,6 +2998,22 @@ recurring nag. It is deliberately not called `--force`, and deliberately not
29972998
default-on. The other answer is to edit the app's metadata to
29982999
`unique: 'organization'` and rebuild.
29993000

3001+
**`--confirm-access-upgrade <token>` confirms exactly the findings it names.**
3002+
Upgrading an installed package stops with `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
3003+
when the new version would un-make an access choice made in the runtime
3004+
(ADR-0126 §6): a permission set, position or sharing rule switched off there
3005+
that the upgrade renames, so the new name is active again; a switch whose name
3006+
it drops; a sharing rule name a rule authored there already holds; an object,
3007+
field or capability a permission set or sharing rule authored there names and
3008+
the upgrade drops; a field deny the upgraded set gains that a set authored there
3009+
lacks. Nothing is installed. The command prints each finding and the exact
3010+
retry, carrying the finding set's token. Review the findings first: the switch
3011+
you meant to keep is fixed by switching the new name off too, after the upgrade.
3012+
The token is not a standing yes — once the findings change it no longer matches
3013+
and the upgrade stops again with the new set; the confirmation is recorded in
3014+
the install's ledger entry. Under `group` and `isolated` only the platform
3015+
operator reaches this check. A fresh install is never checked.
3016+
30003017
A `404` means the target runtime does not mount `MarketplaceInstallLocalPlugin`
30013018
(from `@objectstack/cloud-connection`). The endpoint is opt-in, so a runtime
30023019
composed without it will not accept installs.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data);
352352

353353
| Property | Type | Required | Description |
354354
| :--- | :--- | :--- | :--- |
355-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
355+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
356356
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
357357

358358
### Allowed Values: `FlowInnerRefusal.code`
@@ -406,6 +406,7 @@ const result = AutomationApiErrorCode.parse(data);
406406
* `EXTERNAL_SERVICE_ERROR`
407407
* `INTEGRATION_ERROR`
408408
* `WEBHOOK_DELIVERY_FAILED`
409+
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
409410
* `ACCOUNT_LOCKED`
410411
* `ACTION_CONFIRMATION_REQUIRED`
411412
* `ACTION_DISABLED`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data);
8989
* `EXTERNAL_SERVICE_ERROR`
9090
* `INTEGRATION_ERROR`
9191
* `WEBHOOK_DELIVERY_FAILED`
92+
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
9293
* `ACCOUNT_LOCKED`
9394
* `ACTION_CONFIRMATION_REQUIRED`
9495
* `ACTION_DISABLED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ const result = ErrorCode.parse(data);
256256
* `EXTERNAL_SERVICE_ERROR`
257257
* `INTEGRATION_ERROR`
258258
* `WEBHOOK_DELIVERY_FAILED`
259+
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
259260
* `ACCOUNT_LOCKED`
260261
* `ACTION_CONFIRMATION_REQUIRED`
261262
* `ACTION_DISABLED`

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ export default class PackageInstall extends Command {
117117
+ "installing into an 'isolated'-posture runtime (ADR-0120 D5e). Recorded in the install manifest; asked once.",
118118
default: false,
119119
}),
120+
// [ADR-0126 §6 wall 3] The confirmation of an upgrade's access findings —
121+
// a switch-off a vendor rename re-enables, a choice whose name the
122+
// package no longer ships. A TOKEN, not a boolean: the finding set's
123+
// digest the runtime's refusal printed, so it confirms exactly the
124+
// findings shown, and a set that changed since is refused again.
125+
'confirm-access-upgrade': Flags.string({
126+
description:
127+
"Confirm the access findings of this upgrade with the finding-set token the runtime's refusal printed "
128+
+ '(ACCESS_UPGRADE_CONFIRMATION_REQUIRED). It confirms exactly those findings; a changed set is refused again.',
129+
}),
120130
};
121131

122132
async run(): Promise<void> {
@@ -198,6 +208,7 @@ export default class PackageInstall extends Command {
198208
// ---- Install ---------------------------------------------------------
199209
printStep(`Installing ${label} into ${runtime}...`);
200210
if (flags['confirm-global-uniques']) body.confirmGlobalUniques = true;
211+
if (flags['confirm-access-upgrade'] !== undefined) body.confirmAccessUpgrade = flags['confirm-access-upgrade'];
201212
const installMode = flags['install-mode'] as InstallMode | undefined;
202213
if (installMode !== undefined) body.installMode = installMode;
203214
const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, {
@@ -227,6 +238,20 @@ export default class PackageInstall extends Command {
227238
this.exit(1);
228239
return;
229240
}
241+
// [ADR-0126 §6 wall 3] The access upgrade stop: every finding, then
242+
// the exact retry — the decision is the installer's, per finding.
243+
if (res.body?.error?.code === 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED') {
244+
const details = res.body.error.details ?? {};
245+
const findings: Array<{ describe?: string }> = Array.isArray(details.findings) ? details.findings : [];
246+
printError("Upgrade stopped — it would change what this system's access choices cover");
247+
console.log('');
248+
for (const finding of findings) console.log(` • ${finding.describe ?? JSON.stringify(finding)}`);
249+
console.log('');
250+
console.log(' Review each finding. To upgrade anyway, confirm exactly these findings:');
251+
console.log(` os package install ${args.package} --confirm-access-upgrade ${details.findingSet}`);
252+
this.exit(1);
253+
return;
254+
}
230255
if (res.status === 401) {
231256
printError(
232257
'The runtime rejected the call as unauthenticated. Pass --email/--password ' +
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0126 §6 wall 3] `os package install --confirm-access-upgrade <token>` —
5+
* the install-local body's `confirmAccessUpgrade`, and how the command renders
6+
* the runtime's `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` refusal.
7+
*
8+
* Pinned:
9+
* - the flag reaches the body as `confirmAccessUpgrade`, the token verbatim;
10+
* without the flag the body carries no such key;
11+
* - the refusal prints every finding's `describe` line and the exact retry,
12+
* naming the finding set's token, and exits 1.
13+
*/
14+
15+
import { describe, it, expect, afterEach, vi } from 'vitest';
16+
import PackageInstall from '../src/commands/package/install.js';
17+
18+
type Answer = { ok: boolean; status: number; body: unknown };
19+
20+
function stubRuntime(answer: Answer): { sent: Array<Record<string, unknown>> } {
21+
const sent: Array<Record<string, unknown>> = [];
22+
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: { body?: string }) => {
23+
if (init?.body) sent.push(JSON.parse(init.body));
24+
return {
25+
ok: answer.ok,
26+
status: answer.status,
27+
statusText: answer.ok ? 'OK' : 'Refused',
28+
headers: { get: () => null },
29+
json: async () => answer.body,
30+
} as any;
31+
}));
32+
return { sent };
33+
}
34+
35+
async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> {
36+
const lines: string[] = [];
37+
const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); };
38+
vi.spyOn(console, 'log').mockImplementation(capture);
39+
vi.spyOn(console, 'error').mockImplementation(capture);
40+
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any);
41+
let exit: number | undefined;
42+
try {
43+
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]);
44+
} catch (err: any) {
45+
exit = err?.oclif?.exit ?? err?.code ?? 1;
46+
}
47+
return { out: lines.join('\n'), exit };
48+
}
49+
50+
const TOKEN = 'security.access_upgrade:0123456789abcdef0123456789abcdef';
51+
const INSTALLED = {
52+
manifestId: 'com.acme.crm', version: '2.0.0', versionId: 'ver_2', installedAt: '2026-10-23T00:00:00.000Z',
53+
hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages',
54+
};
55+
56+
describe('os package install --confirm-access-upgrade (ADR-0126 §6 wall 3)', () => {
57+
afterEach(() => {
58+
vi.unstubAllGlobals();
59+
vi.restoreAllMocks();
60+
});
61+
62+
it('sends the token verbatim as `confirmAccessUpgrade`', async () => {
63+
const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } });
64+
await runInstall(['--confirm-access-upgrade', TOKEN]);
65+
expect(sent).toHaveLength(1);
66+
expect(sent[0]).toMatchObject({ packageId: 'com.acme.crm', confirmAccessUpgrade: TOKEN });
67+
});
68+
69+
it('sends no `confirmAccessUpgrade` without the flag', async () => {
70+
const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } });
71+
await runInstall([]);
72+
expect(sent[0]).not.toHaveProperty('confirmAccessUpgrade');
73+
});
74+
75+
it('renders the refusal: every finding, then the exact retry with the token, and exits 1', async () => {
76+
stubRuntime({
77+
ok: false,
78+
status: 409,
79+
body: {
80+
success: false,
81+
error: {
82+
code: 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED',
83+
message: 'Upgrading "com.acme.crm" would change what access choices made in this system cover',
84+
details: {
85+
manifestId: 'com.acme.crm',
86+
findingSet: TOKEN,
87+
findings: [
88+
{ kind: 're-enablement', describe: "permission 'crm_rep' is switched off in this system and the upgrade no longer ships it" },
89+
{ kind: 'broken-reference', describe: "permission set 'cust_exporter', authored in this system, names capability 'crm.export'" },
90+
],
91+
},
92+
},
93+
},
94+
});
95+
const { out, exit } = await runInstall([]);
96+
expect(exit).toBe(1);
97+
expect(out).toContain("permission 'crm_rep' is switched off in this system");
98+
expect(out).toContain("names capability 'crm.export'");
99+
expect(out).toContain(`os package install com.acme.crm --confirm-access-upgrade ${TOKEN}`);
100+
});
101+
});

‎packages/cloud-connection/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
},
3535
"devDependencies": {
3636
"@objectstack/lint": "workspace:*",
37+
"@objectstack/plugin-security": "workspace:*",
3738
"@types/node": "^26.6.3",
3839
"typescript": "^6.0.3",
3940
"vitest": "^4.1.11"

‎packages/cloud-connection/src/local-manifest-source.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ export interface InstalledManifestEntry {
7878
* boot-time nagging #4884 forbids.
7979
*/
8080
globalUniqueAttestation?: GlobalUniqueAttestation;
81+
/**
82+
* [ADR-0126 §6 wall 3] The installer's confirmation of the access-class
83+
* findings this upgrade created — a switch-off a vendor rename re-enables,
84+
* a choice whose name the package no longer ships. Recorded for the audit:
85+
* the finding set confirmed (its digest), by whom, when. It answers only
86+
* the install that wrote it; the next upgrade is checked afresh, and the
87+
* boot report still raises whatever stands.
88+
*/
89+
accessUpgradeConfirmation?: { findingSet: string; confirmedBy: string; confirmedAt: string };
8190
}
8291

8392
/**

0 commit comments

Comments
 (0)