Skip to content

cloud-connection, plugin-security: a marketplace upgrade that would un-make an access choice is refused until it is confirmed (#24446 U2) - #25355

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24446-u2-marketplace-access-refusal
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24446-u2-marketplace-access-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 23, 2026
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/plugin-security, @objectstack/spec, touching 48 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/cloud-connection/vitest.config.ts, packages/plugins/plugin-security/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 303aef069ebdfee276d4b053a11c1bc2e8fa0e90.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cloud-connection/vitest.config.ts, packages/plugins/plugin-security/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 156 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 303aef069ebdfee276d4b053a11c1bc2e8fa0e90 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3e0152e3a74d636ffede54ce555c28ff51459a0a — the merge of head 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663 into base 303aef069ebdfee276d4b053a11c1bc2e8fa0e90, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e0152e3a74d636ffede54ce555c28ff51459a0a && git checkout 3e0152e3a74d636ffede54ce555c28ff51459a0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 303aef069ebdfee276d4b053a11c1bc2e8fa0e90 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663 && git checkout -B drift-repro 303aef069ebdfee276d4b053a11c1bc2e8fa0e90 && git merge --no-ff 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663

node scripts/docs-audit/affected-docs.mjs --json 303aef069ebdfee276d4b053a11c1bc2e8fa0e90

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 303aef069ebdfee276d4b053a11c1bc2e8fa0e90 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ac8ce968761bf36751f5a5c60ff6cc884524aca7
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/marketplace-install-local-access-upgrade.test.ts, packages/cloud-connection/src/local-manifest-source.ts, packages/cloud-connection/package.json, packages/cloud-connection/vitest.config.ts, packages/plugins/plugin-security/src/access-upgrade-check.ts, packages/plugins/plugin-security/src/access-upgrade-check.test.ts, packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/cli/src/commands/package/install.ts, packages/cli/test/package-install-access-upgrade.test.ts, packages/spec/src/api/error-code-ledger.zod.ts, pnpm-lock.yaml + packages/plugins/plugin-security/src/access-upgrade-report.ts (U1: collectAccessUpgradeFindings, accessUpgradeSummary, accessUpgradeDedupKey, hasAccessUpgradeFindings), packages/core/src/security/security-catalog.ts (createSecurityCatalogReader), packages/metadata-core/src/meta-write-capability.ts (walledCatalogWriteVerdict), packages/core/src/security/resolve-authz-context.ts (the walled anchor, :1624), packages/cloud-connection/src/install-local-principal.fixtures.ts, packages/objectql/src/registry.ts (refuseSecurityCatalogNameConflicts :3081, getPackage :6172, installPackage :5960), packages/objectql/src/security-catalog-namespace.ts, packages/objectql/src/plugin.ts (refuseEnvironmentHeldSecurityCatalogNames :2517), packages/plugins/plugin-sharing/src/sharing-rule-service.ts (listDeclaredRules :1063), packages/plugins/plugin-sharing/src/sharing-plugin.ts (:384, :700), packages/spec/src/api/errors.zod.ts (SERVICE_UNAVAILABLE :113), packages/cli/src/commands/serve.ts (:4494), scripts/pm/clause2-line.mjs and scripts/check-changeset-no-major.mjs and scripts/check-adr-0087-registration.mjs read at origin/main (the clone's working tree is 286 commits behind main and predates ruling A). The prose faces, judged under ②: .changeset/24446-u2-marketplace-access-upgrade-refusal.md, content/docs/deployment/cli.mdx, content/docs/references/api/automation-api.mdx, content/docs/references/api/contract.mdx, content/docs/references/api/error-code-ledger.mdx.

① Derived judgments

Line numbers are the head's unless a file is named as new (then the diff's).

  1. Placement of the pre-write check — right. handleInstall converts the body forward once at :1686 (moved up from its old place under step 3, pure, so nothing changes in what registers) and runs step 2c at :1700–1706 only when findConflict === 'marketplace'; the first write stays manifestService.register at :1727, and the ledger write at :1771. findConflict (:2832) answers marketplace for a live ledger entry or for a package the registry holds with no ledger entry and no boot snapshot (the orphaned-install branch), none for a fresh install, user-code refused at step 2. So the check runs on every managed re-install path through this door and never on a fresh install: mountWithV1 pins check uncalled on the version-1 install for all 34 cases, and the unmanaged mode leaves at :1583 before step 2 (a copy, never an upgrade, by ADR-0131 D6). On the orphaned branch previousEntry is null and the check falls back to the registry's own record of the package (registeredManifest, access-upgrade-check.ts :236–240, registry.getPackage(id).manifest), which the plugin-security suite drives over a real ObjectQL ("version 1 read from the registry"). The D5e gate (2b) stays ahead of 2c, so a caller can meet two refusals in sequence; both are 409, both carry confirmWith, and the CLI renders each.

  2. Delta-only refusal and the projection — right, with two notes for ③. The check (createAccessUpgradeCheck, :249–274) runs U1's collectAccessUpgradeFindings unchanged twice and answers accessUpgradeDelta(before, after). The projection replaces exactly P's registry items: projectCatalog (:133–149) drops entries whose packageId === P and adds the incoming permissions/positions/capabilities as entries stamped packageId: P with no _provenance, so isTenantAuthored keeps reading them as managed; projectSharingRules (:152–167) does the same for the declared rules; projectEngine (:170–189) answers an incoming object with the registry's field set less what P's previous version declared plus the incoming declaration, answers an object P dropped as absent, and passes find through so the ledger and sys_sharing_rule rows are never projected. The previous version is the ledger entry's body converted forward (:2459) or the registry's record. accessUpgradeDelta (:198–233) subtracts orphans and collisions by whole-value identity and keeps only the grown candidates, missing names and fields of the other three kinds, so a finding that predates the upgrade or that another package caused never blocks (pinned in both suites, ablation 2 reddened the pin). Two notes: (a) projectCatalog.list also drops an entry of ANOTHER holder whose name the incoming manifest ships (!names.has(e.name)), so a customer clone bearing the incoming name disappears from the projected run instead of surfacing as a collision; see ③ E2. (b) an orphan row with active: true (a dead switch, not a switch-off) is also a finding and refuses: ruling item 1 lists orphans without qualification, so this is within the ruling, and describeAccessFindings names it as "carries a switch".

  3. The confirm token — right. findingSet = accessUpgradeDedupKey(delta): a sha256 over accessUpgradeSummary, which carries every field of every finding (type, name, packageId, candidates with their binding positions, rowId and organization of a collision, each missing name, each uncovered field), 32 hex characters. The door (:2486–2494) recomputes the set on the confirming call and admits only typeof token === 'string' && token === findingSet; a bare true fails the type test, a stale token fails the comparison and is refused again with the fresh set and a sentence saying why (pinned: token installs and is recorded; true; stale). The confirmation { findingSet, confirmedBy: userId, confirmedAt } is written into the ledger entry only when findings existed (:1763); the CONTROL pin reads it absent. One property to know, not a defect: the digest excludes the incoming version string, so two incoming versions producing the identical finding set share a token — "the token confirms exactly the findings it names" is what the PR says and what it does.

  4. Walled operator-gated, single the administrator — right. refuseWalledCatalogPackage (:1470, body at :3950) runs after the id gate and before 1c, 1e, 1d, 2, 2b and 2c; walledCatalogWriteVerdict admits only the PLATFORM_ADMIN rung under group/isolated; its refusal body is 403 FORBIDDEN with details: { manifestId } and nothing else, so no finding reaches a tenant administrator and the check never runs for one (pinned: check not called, details.findings undefined, for both postures and all four types). Under single the manage_metadata holder (admin_full_access) reaches 2c and their token admits. R4 in the os-dev-report measured D12 itself on a production walled serve (tenant owner 403 at the capability gate; with manage_metadata in their organization, 403 from D12; no finding; no "stopped" line). Pin-precision note, not a defect: the door suite's ctx has no getKernel, so resolveAdmissionTenancyPosture (:3753) answers undefined and walledCatalogWriteVerdict is inert there; the suite's tenant administrator is refused one gate earlier, by requireInstallCapability, because under a requested wall no stored grant anchors standing (resolve-authz-context.ts:1624, OS_TENANCY_POSTURE). The asserted outcome is the same (403 FORBIDDEN, nothing shown, check uncalled), and D12 was measured in R4; the docblock's "refused by the D12 wall" names the later of the two gates.

  5. A stopped check fails closed — right. U1's three stops (scan-unreadable, catalog-unreadable, sharing-unreadable), the check's own unbound (before start() binds the engine and the metadata service) and a throw from the service all answer 503 SERVICE_UNAVAILABLE (a standard-catalog member, errors.zod.ts:113, the 503 derivation) with details.reading, and nothing is registered or written (pinned at the door with the ledger offline; pinned in plugin-security for scan-unreadable and unbound).

  6. No security.accessUpgradeCheck in the composition — fail-open with one warn, acceptable as measured and GO'd, with one residual escalated (③ E1). The measurement (6355301005 §2, "Fail closed") proposed exactly this limb and the seat's GO (6355317159) is "build as measured". Weighed: ruling 6259332195 item 2 binds the access class at marketplace upgrade; ADR-0126 wall 3 says an upgrade never writes the ledger and never un-makes a choice. The door writes no ledger row in either limb, so wall 3's literal clause holds; what the absent limb cannot do is see whether a choice is un-made in effect. In a composition without plugin-security no permission set, position or capability is enforced, so there is no access choice of those three types whose effect an upgrade could un-make, and refusing every re-install there would make plugin-security a hard dependency of the marketplace door, a composition decision outside this card. The residual: the absence test is "no security.accessUpgradeCheck service" (:2451), which is "no plugin-security", not "no access enforcement". plugin-sharing composes without plugin-security (sharing-plugin.ts:384 depends on objectql alone; :700 probes security optionally) and enforces the sharing_rule switch-off itself, so in that composition a vendor rename of a switched-off rule would go unchecked and the warn line's sentence ("enforces no switch-off ... an upgrade could un-make") over-claims. Every shipped composition in this repository composes SecurityPlugin (serve.ts:4494), so the residual is theoretical here. A present service without a check function is also read as absent (:2452); a narrower limb, same disposition.

  7. No runtime dependency on plugin-security — right. cloud-connection/package.json dependencies are unchanged; @objectstack/plugin-security enters devDependencies only (and the lockfile gains the one link). The door imports nothing from it: the service name is a local constant (:349) and the answer is a local structural type (:355–372); vitest.config.ts aliases the package to this checkout's source for the door suite, the pattern the file's other entries follow. No cycle: plugin-security depends on neither cloud-connection nor runtime; cloud-connection already depends on runtime.

  8. Every accept-set and public-surface change, named. Narrowing: POST /api/v1/marketplace/install-local on a managed re-install now answers 409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED (or 503) where it used to install — declared (narrowing), banner present. Widenings, each right: the confirmAccessUpgrade request-body key (a string token; true is accepted syntactically and refused semantically); error.details.{manifestId, findingSet, findings[], confirmWith{body, cli}} with findings[].kind in re-enablement | orphan | collision | broken-reference | uncovered-deny and a describe line each; the optional accessUpgradeConfirmation on the exported InstalledManifestEntry (local-manifest-source.ts:88); plugin-security's three value exports and five type exports (index.ts:141–155) and the security.accessUpgradeCheck service registered in init() (security-plugin.ts:1776) over sources resolved per call; the CLI's --confirm-access-upgrade string flag and its refusal rendering (every describe line, then the exact retry); the new ledger row. Cosmetic: the CLI prints details.findingSet unguarded, so a runtime that omitted it would print the word undefined.

  9. Tests, as pinned. Door: 4 types × (single 409, single CONTROL, isolated and group operator 409, isolated and group tenant administrator 403, isolated CONTROL) = 28, plus token/true/stale, predates, stopped 503, no-service 200 — 34. No group CONTROL at the door; group and isolated share postureEnforcesWall, and the isolated CONTROL covers the path. plugin-security: 15 over a real ObjectQL and SQL driver with the plugin's own registration, including the two clone-body cases the card names. CLI: 3. Ablations: three, each red then restored. The 43 check-runs on the head conclude success or skipped-by-design; none fails.

② Semver level

  • Clause-②: yes (narrowing) on both carriers — right. Read against scripts/pm/clause2-line.mjs at origin/main: the key at line start in the fixed spelling, the value token yes first after the colon, the arm (narrowing) as the next non-blank parenthetical in the closed spelling, trailing reasoning tolerated, the key named once per line, no open backtick span. The changeset line and the PR-body line are byte-identical. A diff that widens (a service, exports, a code, a body key, a flag) and narrows (the door's answer on re-install) is exactly the yes (narrowing) combination the reader documents.
  • Metadata-protocol: none — right. Nothing a metadata author writes changes: an error code is wire vocabulary, confirmAccessUpgrade is a request-body key, accessUpgradeConfirmation is a runtime ledger-file field, the service and exports are runtime, the flag is CLI. No authorable-surface/ or authorable-defaults/ baseline moves (the diff touches none); Spec property liveness is green.
  • patch on cloud-connection, plugin-security, spec, cli — right. Under maintainer ruling A (2026-10-21, changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105; AGENTS.md at origin/main :1091–1093), the level follows the metadata-protocol kind alone: none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The gate's own self-test pins yes (widening) + none and no (narrowing) + none to the none-clean lane; Check Changeset on this head concluded success. The clone's working-tree copy of the gate and of AGENTS.md predates ruling A (there yes took at least minor); the review read the main versions, and the house rule the brief states is ruling A.
  • The BREAKING banner and the ADR-0087 marker — both present, both right. **BREAKING** — opens the body's first paragraph; the marker is not-required (no-migration-prescription), which is honest: no metadata schema key is added, removed, renamed or re-shaped, so objectstack migrate meta has nothing to rewrite and no tombstone exists; the "migration" the body states is a runtime ceremony on the same call (re-send with the finding set's token), not a rewrite of consumer code or metadata, which is the one thing that category refuses. The gate at origin/main reads the (narrowing) arm as a breaking signal and found its marker; Lint & Repo Gates is green. A narrowing WITHOUT the banner or the marker would be a defect: during the launch window those two are the only carriers of breaking-ness, since the level no longer carries it.
  • The ADR-0112 ledger row — right. ACCESS_UPGRADE_CONFIRMATION_REQUIRED under @objectstack/cloud-connection, the package whose Hono route stamps it (D3: registered under the owning package, a deliberate reviewable act); SCREAMING_SNAKE; domain-prefixed; standardSynonymOf maps it to no standard member, and the ledger test (error-code-ledger.test.ts, run in Test Core) admits it unwaived; the row's comment carries the meaning, the status, the pre-write guarantee and why neither UNIQUE_SCOPE_CONFIRMATION_REQUIRED (per-index decisions) nor a conflict code (which no confirmation lifts) fits, the same reasoning INSTALL_MODE_NOT_PERMITTED was admitted on. 409 is the D5e gate's status and the request is well formed; no 5xx limb. ADR-0112's "registering a code widens the published face and is a Clause-② change" is declared. The three generated reference pages regenerate (+342 more), and check:generated was run.
  • Size and tier. +1,471 / −9 over 18 files, under 3,000; 0 of 18 paths on the governed register at origin/main (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md, .github/workflows/**); packages/spec is not a register row. Ordinary queue landing once accepted. The --paths recogniser reads the schema face off packages/spec/src/api/error-code-ledger.zod.ts; the Scope line above holds the narrowing floor, which contains it.

③ Boundary flags

  • Seat GO (a), the CLI flag in U2's surface — done. --confirm-access-upgrade TOKEN sends the token verbatim as confirmAccessUpgrade, is absent from the body without the flag, and the refusal prints every describe line and the exact retry; three pins. content/docs/deployment/cli.mdx documents the flag and its semantics in the same words as the door.
  • Seat GO (b), POST /api/v1/packages with overwrite — correctly held. runtime/src/domains/packages.ts is untouched; the dev's out-of-scope line names it as the seat's follow-up. The service that door will call is exported and shaped for it (check({ packageId, manifest, previousManifest? }) answers clean | findings | stopped), so the second door calls the same check, not a copy.
  • Seat GO (c), a token and never a bare true — done (① item 3).
  • Dev's added limit ("an object the incoming manifest adds that the registry does not yet hold is judged on its declared fields only") — answered: immaterial. No customer-authored set or sys_sharing_rule row can yet name an object that did not exist, and the uncovered-deny check pairs a managed set with a customer set granting the same object by name, so neither kind can fire on a new object.
  • Dev's out-of-scope (2) (no example app declares @objectstack/organizations; R4 needed temporary edits) — an observation from U1 repeated; not this card's.
  • Dev's out-of-scope (3) (cli.mdx's options table omits --install-mode) — answered: a note suffices under Prime Directive chore: version packages #10 (a delivered flag that one table does not list is not an advertised-undelivered capability); the seat may file a docs card.
  • Open questions in the report: none. Deviations from the GO: none found; the file surface is the measured one plus the CLI test, the vitest alias and the lockfile link the devDependency needs.
  • E1 — escalated to the seat, not a FAIL: the absent-service fail-open limb (① item 6). The seat should rule whether the door's absence test stays "no plugin-security" (as measured and GO'd) or widens to refuse 503 when a sharingRules service is composed and no check is, so a sharing-rule switch-off is never upgraded unchecked in a plugin-sharing-only composition. One line at :2451 plus a pin either way.
  • E2 — escalated to the seat, not a FAIL: catalog-name collisions at the door (① item 2a). The projected catalog replaces, rather than reports, an environment-authored (customer clone) position or permission set whose name the incoming version ships. The registry's package door refuses that second holder at registration (registry.ts:3081, environment holders included, called from installPackage :5960): on the inline branch that is 422 PLUGIN_REGISTER_FAILED before any write (door :1735); on the cloud-fetched branch the lenient register writes the ledger entry anyway (:1745, "whether it should is a separate decision, not made here") and the next cold boot refuses (plugin.ts:2517). That hazard predates this PR and is outside the GO, but ruling item 1 names same-name collisions and U1's GO said "the pre-write check is U2's". Recommend the seat decide whether the follow-up card (the one holding POST /api/v1/packages) adds a collision finding kind for the three catalog types at both doors, or records that registration's refusal is the pre-write answer and the cloud branch's lenient write is a separate card.
  • E3 — stays open on the card, as the GO already says: the repo:cloud request 6355241020. The GO lifted it as a precondition for the framework door; if cloud has an install path of its own, it calls security.accessUpgradeCheck before its first write in cloud's follow-up.
  • Docs drift check (PR comment 6355823895) is advisory and names the generated pages this PR regenerated; cli.mdx's "Who sees it" sentence matches the door's admission.

Implemented-by: claude/issue-24446-u2-marketplace-access-refusal
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

…4446 U2, review E1)

Without the security plugin a re-install is not checked against the access
choices it could un-make; that does not mean the composition enforces none
(plugin-sharing enforces a sharing_rule switch-off on its own).

Claude-Session: https://claude.ai/code/session_01U9Aia8cnVwrpSUL9KgVzdu
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/marketplace-install-local-access-upgrade.test.ts, packages/cloud-connection/src/local-manifest-source.ts, packages/cloud-connection/package.json, packages/cloud-connection/vitest.config.ts, packages/plugins/plugin-security/src/access-upgrade-check.ts, packages/plugins/plugin-security/src/access-upgrade-check.test.ts, packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/cli/src/commands/package/install.ts, packages/cli/test/package-install-access-upgrade.test.ts, packages/spec/src/api/error-code-ledger.zod.ts, pnpm-lock.yaml + packages/plugins/plugin-security/src/access-upgrade-report.ts (U1: collectAccessUpgradeFindings, accessUpgradeSummary, accessUpgradeDedupKey, hasAccessUpgradeFindings), packages/core/src/security/security-catalog.ts (createSecurityCatalogReader), packages/metadata-core/src/meta-write-capability.ts (walledCatalogWriteVerdict), packages/core/src/security/resolve-authz-context.ts (the walled anchor, :1625 at this head) and packages/core/src/security/platform-standing-anchor.ts (organizationAnchorsPlatformStanding :58, where main's #25352 moved the rule), packages/cloud-connection/src/install-local-principal.fixtures.ts, packages/objectql/src/registry.ts (refuseSecurityCatalogNameConflicts :3081, getPackage :6172, installPackage :5960), packages/objectql/src/security-catalog-namespace.ts, packages/objectql/src/plugin.ts (refuseEnvironmentHeldSecurityCatalogNames :2517), packages/plugins/plugin-sharing/src/sharing-rule-service.ts (listDeclaredRules :1063), packages/plugins/plugin-sharing/src/sharing-plugin.ts (:384, :700), packages/spec/src/api/errors.zod.ts (SERVICE_UNAVAILABLE :113), packages/cli/src/commands/serve.ts (:4494), scripts/pm/clause2-line.mjs and scripts/check-changeset-no-major.mjs and scripts/check-adr-0087-registration.mjs read at origin/main. The prose faces, judged under ②: .changeset/24446-u2-marketplace-access-upgrade-refusal.md, content/docs/deployment/cli.mdx, content/docs/references/api/automation-api.mdx, content/docs/references/api/contract.mdx, content/docs/references/api/error-code-ledger.mdx.

This is the delta round on the PASS record posted as comment 6356845701 on the previous head (the first record). The head moved by two commits, both read in full against the clone's objects: the first is a merge of origin/main at b3034ee into the branch (its parents are the previous head and that main commit), and the second, which is this head, is the E1 wording correction (2 files, +9/−8). Where the delta leaves a judgment unchanged, this record says so and carries it forward from the first record rather than restating its evidence.

① Derived judgments

The merge (4977ef8) — right, and it changes nothing U2 claims. The merge base of origin/main and this head is b3034ee, and the net diff b3034eeffa..8989149c is still exactly U2's 18 files, +1,472/−9 (the one extra insertion is the E1 wording, below). Main brought 117 files (+5,346/−1,178); only two of them are also U2's, content/docs/deployment/cli.mdx (#25212's one-line edit elsewhere on the page) and packages/plugins/plugin-security/src/security-plugin.ts (#25324's move of the CBP write gate to security-plugin-cbp-write-gate.ts, −692 lines there). For both, the net diff against b3034ee is byte-for-byte U2's own hunks and nothing else: in security-plugin.ts the one import (:50) and the registerService(ACCESS_UPGRADE_CHECK_SERVICE, createAccessUpgradeCheck({...})) block inside init() (:1732–1746, sources resolved per call, sharingRules through SHARING_RULES_SERVICE); in cli.mdx the options-table row and the 16-line --confirm-access-upgrade paragraph. No file differs from both parents in a way the two sides' hunks do not explain, so nothing was hand-resolved; the dev's "no conflicts" reading holds. Main added no error-code ledger row in the window (contract.mdx reads +341 more on b3034ee and +342 more at this head), so U2's three regenerated reference pages stay consistent with main. Main's #25352 moved the walled platform-standing anchor rule into platform-standing-anchor.ts (organizationAnchorsPlatformStanding, :58); resolve-authz-context.ts still keys it on postureEnforcesWall(resolveTenancyPosture()) at :1625, so the pin-precision note in the first record's item 4 stands with that one line moved. None of the other files cited in the first record changed on main between the old base 54df640 and b3034ee.

E1's text (8989149c2d) — right, and now true in every composition. Three sentences in the door and one in the changeset change, no code path: the service-constant docblock (:349–354: "Without it, a re-install is not checked against the access choices it could un-make, and the door says so once per upgrade"), the evaluateAccessUpgradeGate docblock (:2421–2423: "No check service (no security plugin composed): the re-install is not checked against the access choices it could un-make"), the warn line (:2448–2452: "so this re-install is not checked against the access choices it could un-make. Compose the security plugin to have upgrades checked") and the changeset's "Fail closed" bullet (the same sentence). The old sentence claimed the composition "enforces no switch-off or clone an upgrade could un-make", which over-claimed where plugin-sharing is composed without plugin-security; the new one states what the door knows, that no comparison ran. The absent-service limb's behaviour is unchanged (proceed, one warn), as the seat ruled (③). The no-service pin matches on the surviving phrase no access upgrade check ran, so it still reads the line.

Carried forward unchanged from the first record, re-checked against this head where the delta could have touched them: item 1 (step 2c placed after the D5e gate and before the first write, re-install only, never a fresh install, the orphaned-registry branch falling back to registry.getPackage(id).manifest; the door's code is unchanged by both commits, only its comments and one string moved by at most one line); item 2 (delta-only refusal and the projection, with the same two notes, now E2's card); item 3 (the token bound to the exact finding set, bare true and a stale token refused, the confirmation recorded only when findings existed); item 4 (walled operator-gated through the D12 refusal with nothing leaked, single the manage_metadata holder; the suite's tenant administrator is refused one gate earlier, by the capability admission, and R4 measured D12 itself — the dev's re-measure on this head reports the same: tenant owner 403, tenant admin 403 from D12 with no findings shown, operator 200/409/409/200, and the boot report raising the re-enablement after the restart); item 5 (a stopped check answers 503 and writes nothing); item 7 (no runtime dependency on plugin-security; dependencies unchanged, the devDependency and the vitest alias only); item 8 (every accept-set and public-surface change named, all right); item 9 (34 + 15 + 3 pins, three ablations). The dev's re-measure numbers on this head (plugin-security 4457, cloud-connection 704, R1, R4 under isolated and group) are the dev's readings; this review is read-only and verifies none of them locally, and the head's check-runs are the gate verdicts the seat confirms before posting (③).

② Semver level

Unchanged from the first record, and re-read on this head. The changeset at 8989149c carries Clause-②: yes (narrowing) and Metadata-protocol: none byte-identical to the reviewed version (only the "Fail closed" bullet moved, outside the declaration lines, the banner and the marker); the four patch entries, the **BREAKING** — banner and the not-required (no-migration-prescription) marker are as before. patch ×4 with none is right under maintainer ruling A (2026-10-21, #25105; AGENTS.md at origin/main :1091–1093), the banner and the marker being the breaking-ness carriers the launch window asks for. The PR body's declaration lines are the seat's to confirm on the posted body; this review read them on the staged copy of the pre-delta body, where they matched the changeset. The merge adds no changeset of this PR's (main's seven new changesets are main's own and sit at the branch point for the gate's purposes) and moves no authorable-surface/ or authorable-defaults/ baseline into this diff. The ADR-0112 ledger row is unchanged and still the only row this PR adds; main added none in the window, so the regenerated pages are current. Size +1,472/−9 over 18 files, 0 governed paths on the register at origin/main; ordinary queue landing.

③ Boundary flags

Seat rulings on the first record's escalations, recorded (#24446, 6356868153):

Carried forward unchanged: seat GO (a) the CLI flag in U2's surface, done; (b) POST /api/v1/packages with overwrite correctly held for the seat's follow-up, the exported service shaped for it; (c) a token and never a bare true, done. The dev's added limit (a new object judged on its declared fields) is immaterial; the dev's out-of-scope notes (2) and (3) are answered as before (observation; a docs note suffices under Prime Directive #10). No open questions in the report; no deviation from the GO in either commit.

Posting condition. CI on 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663 was running when this record was rendered; its check-runs are the gate verdicts, and the seat confirms every check green on this head before posting this record.

Implemented-by: claude/issue-24446-u2-marketplace-access-refusal
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-23T04:25Z. PR #25355 at 8989149c2dc0f4f9acd7f8c820e4b7ac89a1c663 (#24446 U2; claim 6355237423; measurement 6355301005; GO 6355317159; report 6356606385). ACCEPT.

Seat check

  • Shape: 18 files, +1,472 / −9:
    • cloud-connection's install door and its tests;
    • plugin-security's access-upgrade-check.ts, its export, its wiring and its tests;
    • the CLI's --confirm-access-upgrade and its test;
    • one packages/spec error-code ledger row;
    • docs and one changeset.
      check-governed-merges --pr 25355: not governed, so this is an ordinary queue landing. It touches no ceremony, plan, harness or tenancy read; the dev ran the harness locally (16/16).
  • Review tier: Clause-②: yes (narrowing), so CONTRACT_REVIEW_TIER on the narrowing face.
    • ac8ce968: PASS 6356845701, with escalations E1–E3, ruled in 6356868153.
    • This head: PASS 6357354613, covering the main merge and E1's wording.
  • What it does (ruling 6259332195 item 2, the access class at marketplace upgrade):
    • On a re-install only, before the first write, the door runs U1's comparison over the booted registry and over the projection with the incoming manifest. It refuses on the delta: 409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED, naming each finding with a findingSet token.
    • The same call with confirmAccessUpgrade set to that token installs. A bare true or a stale token is refused again.
    • A stopped check answers 503 and writes nothing.
    • Walled: operator-gated through the existing D12 refusal; a tenant administrator gets 403 and sees no finding. single: the administrator.
    • The check is the plugin-security service security.accessUpgradeCheck. cloud-connection takes no runtime dependency on it.
  • Rulings on the escalations:
  • CI on the head: 43 check-runs, every one success or skipped, including Test Core, Build Core, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard and Check Changeset. The unlabelled harness lane is skipped. Mergeable clean. main is merged in at 4977ef8bb3 (b3034eeffa).
  • R1–R4, measured by the dev on this head:
    • plugin-security 4,457 passed, cloud-connection 704 passed; build, typecheck and the changeset gates exit 0.
    • R1 under single, at the door: 200 / 200 / 200 / 409 / 409 / 200 (install, switch-off, CONTROL, rename, bare true, token).
    • R4 under isolated and group:
      • operator: 200 / 409 / 409 / 200;
      • tenant owner: 403 at the capability gate;
      • tenant administrator: 403 from D12, with no finding shown;
      • after a restart, U1's boot report raises the re-enablement;
      • no refused write.
    • R2 and R3: the harness passed 16/16, both fixture legs. The dogfood battery passed (2,491 tests) on the code head.
  • Changeset: cloud-connection, plugin-security, spec and cli at patch, Clause-②: yes (narrowing), Metadata-protocol: none, BREAKING banner, ADR-0087 not-required (no-migration-prescription). Right under the house rule.
  • Landing: the seat flips the PR ready and enables auto-merge. Part of #24446: the card stays open for the ADR-0126 dated note (ruling item 5). The POST /api/v1/packages overwrite door is filed to the shared queue after this lands (GO item b).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 23, 2026 04:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 23, 2026 04:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 448374d Oct 23, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24446-u2-marketplace-access-refusal branch October 23, 2026 05:00
os-steve pushed a commit that referenced this pull request Oct 23, 2026
…cords the single-posture re-slug rule

ADR-0126 §6 wall 3 gains a dated note: the boot report (#25311,
54df640) and the marketplace refusal (#25355, 448374d) make it true
in practice for sharing rules, permission sets, positions and
capabilities. The note names who is told under `single` and under a wall,
the findingSet confirm token, the two accepted limits under §9, the two
doors still open (#25382, #25366), and that the other classes follow
after v18.

ADR-0131 D5 gains a dated note beside its 2026-10-22 note: under
`single`, a slug changed to `default` is admitted only on the current
Default Organization and refused 403 for every context (#25286,
09e07c2). It names the removed move and the stranding it leaves.

Notes only; no decision text changes.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbVJUgUf5HuWXPQvAo4sFg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants