Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/32689-activation-door-two-holder-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/runtime': patch
---

fix(runtime)!: the action activation door answers a name two packages hold with `409` naming both, as the execution door does, instead of `404` "has no declaration"

Clause-②: yes (narrowing)
Metadata-protocol: none

BREAKING, for `POST /api/v1/actions/_activation/OBJECT/NAME` (body `{ "enabled": true | false }`) where two packages each declare an action `NAME` on that route's key — both object-less (`OBJECT` = `global`), or both bound to `OBJECT`:

- FROM: `404 RESOURCE_NOT_FOUND`, "Action 'NAME' on 'OBJECT' has no declaration — there is nothing to switch off" (or "on").
- TO: `409 RESOURCE_CONFLICT`, the declaration reading's refusal, naming every holder in the message and in `error.details.candidates` (each with its `packageId`). The execution door `POST /api/v1/actions/OBJECT/NAME` already answers this name with the same refusal.

In both directions nothing is written to the activation ledger, as before. The declaration reading refused the name, and the activation door read that refusal as "no declaration", so it reported an ambiguity as an absence (ADR-0150 D3: ambiguity is reported, never answered as absence).

Unchanged: a name one package declares switches off and on with its ledger row naming that package; a name nothing declares answers `404` "has no declaration"; one package's action bound to two objects answers the activation door's own `409` naming both objects.

The fix for a caller: read the `409` as "two packages hold this name", not as a missing action. The ledger addresses an action by its name, so this door switches the name again once one package holds it; before this change the same request answered `404` and switched nothing either.

<!-- adr-0087: not-required (no-migration-prescription) no key, export, field or stored body changes; the change is the status of a refusal the activation door already made, now answered as the execution door answers the same name; a caller holding two packages' action of one name must choose which package's action to switch, which no conversion can choose for them -->
82 changes: 68 additions & 14 deletions packages/runtime/src/domains/action-activation-unpackaged.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const ADMIN = (): HttpProtocolContext => ({
const statusOf = (r: any): unknown => r?.response?.status;
const codeOf = (r: any): unknown => r?.response?.body?.error?.code;
const bodyOf = (r: any): string => JSON.stringify(r?.response?.body);
const candidatesOf = (r: any): unknown => r?.response?.body?.error?.details?.candidates;

const flip = (h: Harness, objectName: string, actionName: string, enabled: boolean) =>
h.dispatcher.handleActions(`/_activation/${objectName}/${actionName}`, 'POST', { enabled }, ADMIN());
Expand Down Expand Up @@ -314,8 +315,9 @@ describe('#22817 — a refused ledger write keeps ADR-0112\'s code and status to
*
* A stack whose `protocol` serves the merged listing (the composed artifact
* on `MetadataPlugin` + `ObjectQLPlugin` + `AppPlugin`) already saw both and
* answers the same before and after: 409 for the pair on two objects, 404 for
* the object-less pair. This host now answers what that stack answers.
* answers the same before and after: 409 for the pair on two objects, and
* (since #32689) 409 naming both packages for the object-less pair. This host
* now answers what that stack answers.
*
* BASE (main `874166b60`'s `@objectstack/metadata` built): 200 and one row
* (`package_id` = the package saved last) in both cases.
Expand Down Expand Up @@ -357,14 +359,63 @@ describe('[#32517] two packages\' same-named actions: the metadata-service-only
expect(h.rows).toEqual([]);
});

it('NARROWING — both object-less: 404, no row, as the merged-listing stack answers (BASE: 200 and one row)', async () => {
/**
* [#32689] The declaration reading refuses the name, and the activation
* door answers that refusal as the execution door does — 409 naming both
* packages, in both directions — never "has no declaration" (BASE of
* #32689: 404 RESOURCE_NOT_FOUND; BASE of #32517: 200 and one row).
*/
it('NARROWING — both object-less: 409 RESOURCE_CONFLICT naming both packages, on and off, and no row', async () => {
const h = await bootWithDoor([undefined, undefined]);
for (const enabled of [false, true]) {
const res = await flip(h, 'global', 'shared_ping', enabled);
expect(statusOf(res), bodyOf(res)).toBe(409);
expect(codeOf(res)).toBe('RESOURCE_CONFLICT');
expect(candidatesOf(res)).toEqual(expect.arrayContaining([
expect.objectContaining({ packageId: 'com.acme.alpha' }),
expect.objectContaining({ packageId: 'com.acme.beta' }),
]));
expect(bodyOf(res)).toContain('com.acme.alpha');
expect(bodyOf(res)).toContain('com.acme.beta');
}
expect(h.rows).toEqual([]);
});

it('CONTROL — a name nothing declares: 404 RESOURCE_NOT_FOUND "has no declaration", and no row (unchanged)', async () => {
const h = await bootWithDoor([undefined, undefined]);
const res = await flip(h, 'global', 'shared_ping', false);
expect(statusOf(res), bodyOf(res)).toBe(404);
expect(codeOf(res)).toBe('RESOURCE_NOT_FOUND');
for (const enabled of [false, true]) {
const res = await flip(h, 'global', 'nobody_ping', enabled);
expect(statusOf(res), bodyOf(res)).toBe(404);
expect(codeOf(res)).toBe('RESOURCE_NOT_FOUND');
expect(bodyOf(res)).toContain('has no declaration');
}
expect(h.rows).toEqual([]);
});

/**
* [#32689] One holder of the object-less name: the flip writes its row
* naming that package, in both directions — each on a fresh ledger, since
* the double writes FIRST rows only (see {@link makeLedgerTable}).
*/
it('CONTROL — one package\'s object-less action: the flip writes its row, on and off (unchanged)', async () => {
for (const enabled of [false, true]) {
const door = new MemoryLoader();
await door.save('action', 'shared_ping', shipped('com.acme.alpha', undefined));
const manager = new MetadataManager({ formats: ['json'], loaders: [door] });
const h = boot({ metadata: {
load: (type: string, name: string) => manager.load(type, name),
loadDiagnosed: (type: string, name: string) => manager.loadDiagnosed(type, name),
loadMany: (type: string) => manager.loadMany(type),
listObjects: async () => [],
getObject: async () => undefined,
} });
const res = await flip(h, 'global', 'shared_ping', enabled);
expect(statusOf(res), bodyOf(res)).toBe(200);
expect(h.rows.map((r) => [r.name, r.package_id, r.active]))
.toEqual([['shared_ping', 'com.acme.alpha', enabled]]);
}
});

it('NARROWING — the bare execution door, both object-less: 409 naming both packages, nothing runs (BASE: 200, the one saved last ran)', async () => {
const h = await bootWithDoor([undefined, undefined]);
const res = await h.dispatcher.handleActions('/global/shared_ping', 'POST', {}, ADMIN());
Expand Down Expand Up @@ -438,18 +489,21 @@ describe('[#32714] the metadata-service-only host: two packages on one object, a

/**
* Refused, and no row: the flip of a name two packages hold on this object
* switches neither. It answers 404 "has no declaration", the answer the
* object-less pair gets above: the declaration read refuses the name
* (the execution door's 409 below) and the activation door drops that
* refusal for its "no declaration" branch. That door's 404 → 409 is
* #32689's, for every key; this pin moves with it.
* switches neither. The declaration read refuses the name, and the
* activation door answers that refusal as the execution door below does
* — 409 naming both packages — for every key (#32689; BASE: 404
* RESOURCE_NOT_FOUND "has no declaration", the refusal dropped).
*/
it('two packages bound to ONE object — the activation door: refused, 404 RESOURCE_NOT_FOUND, and no row', async () => {
it('two packages bound to ONE object — the activation door: refused, 409 RESOURCE_CONFLICT naming both packages, and no row', async () => {
const h = await host([[ALPHA, OTHER], [BETA, OTHER]]);
for (const enabled of [false, true]) {
const res = await flip(h, OTHER, 'shared_ping', enabled);
expect(statusOf(res), bodyOf(res)).toBe(404);
expect(codeOf(res)).toBe('RESOURCE_NOT_FOUND');
expect(statusOf(res), bodyOf(res)).toBe(409);
expect(codeOf(res)).toBe('RESOURCE_CONFLICT');
expect(candidatesOf(res)).toEqual(expect.arrayContaining([
expect.objectContaining({ packageId: ALPHA }),
expect.objectContaining({ packageId: BETA }),
]));
}
expect(h.rows).toEqual([]);
});
Expand Down
15 changes: 14 additions & 1 deletion packages/runtime/src/domains/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ import {
activationWriteContext,
ACTION_ACTIVATION_SUBJECT,
} from './activation-gate.js';
// [ADR-0150 D3/D5] The one 409 answer to a refused name, the envelope the
// `/actions` execution door answers its own refusal with.
import { refusedNameAnswer } from './catalog-activation.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
import { readDispatchedBody } from '../deferred-request-body.js';
Expand Down Expand Up @@ -152,7 +155,10 @@ function isActionActivationWrite(parts: string[], method: string): boolean {
* 3. **Declaration**, through the SAME `resolveRouteActionDeclaration` the
* invocation door uses, so the two can never disagree about which actions
* exist. Unknown → 404 (the #7535 shape: a typo must not read as a server
* fault), metadata plane unavailable → 503 (an outage is not a verdict).
* fault), metadata plane unavailable → 503 (an outage is not a verdict),
* a name two holders declare on the key → the reading's refusal, 409
* naming every candidate (ADR-0150 D3: ambiguity is reported, never
* answered as absence).
* 4. **Ambiguity refusal.** The ledger addresses an artifact by machine name
* (§4). Two objects declaring the same action name therefore address ONE
* row, so this refuses instead of silently switching both off.
Expand Down Expand Up @@ -232,6 +238,13 @@ async function handleActionActivationWrite(
),
};
}
// [ADR-0150 D3/D5, #32689] A name two holders declare on this route's key
// resolves to no one declaration: the reading carries the refusal naming
// every candidate, and this door answers it as the execution door does
// (`409 RESOURCE_CONFLICT`, the candidates in the envelope) — in both
// directions, before the "no declaration" branch below, which would read
// the ambiguity as absence. Nothing is written.
if (declaration.refusal) return refusedNameAnswer(deps, declaration.refusal);
if (!declaration.action) {
return {
handled: true,
Expand Down
Loading