Scoped Action Mapping Selectors Contract Review
The implemented first Actor slice indexes local Action names in one flat map
per Source. That was sufficient for one form per Source, but it makes the
authored Block boundary dishonest: two Blocks in one lesson cannot both map
the natural local name save, submit, or check. The second name is treated
as a duplicate even though the mappings have different stable Block owners.
Build Week's durable interactions make this a current requirement. A lesson should be able to contain several independent Blocks that each say:
actions:
save: responses.save
This packet defines the implemented correction: an Action invocation selects a mapping by
Source plus its authored scope. Source mappings use { type: "source", localName }; Block mappings add blockId. The selector is an untrusted lookup
key. The current Build projection still resolves it to the trusted installed
Action ID and canonical Source/Block target before authority or admission.
There is no compatibility parser or fallback to the flattened index. This is a greenfield contract correction: generated forms, Actor Host, Build adapter, server parser, tests, and docs migrate together.
The maintainer accepted this contract on 2026-07-15. The Actor Host, Build index and adapter, generated forms, server transport, and focused tests now use the scoped selector with no compatibility path to the flattened index.
Mapping Selector
The shared public selector is:
export type PathMXActionMappingSelector =
| Readonly<{
type: "source"
localName: string
}>
| Readonly<{
type: "block"
blockId: string
localName: string
}>
PathMXActionSubmission replaces its ambiguous action: string field with
the selector:
export type PathMXActionSubmission = Readonly<{
invocationId: string
playSession: PathMXPlaySessionRef
source: PathMXActorSourceRef
mapping: PathMXActionMappingSelector
fields: readonly PathMXSubmittedField[]
}>
The source dependency uses the same vocabulary:
export type PathMXActorSources = Readonly<{
// existing members
resolveAction(input: Readonly<{
selectedRoot: PathMXActorSelectedRoot
source: PathMXActorSourceRef
mapping: PathMXActionMappingSelector
}>): Promise<PathMXActionResolution>
}>
The resolved result is unchanged. It includes the trusted local name, qualified Action ID, canonical target, and Source version:
export type PathMXResolvedAction = Readonly<{
source: PathMXActorSourceRef
sourceVersion: string
localName: string
action: PathMXActionId
target: PathMXActionTarget
}>
The selector is not a PathMXActionTarget, capability, authority grant, or
Action ID. For a Block selector, Build must find that exact Block in the
requested Source and find the local mapping on that Block. Only then does it
return the trusted Block target.
Beat mappings remain outside the first Actor slice because the accepted
PathMXActionTarget supports Source and Block only.
Normal Form Encoding
Generated forms retain the ordinary submitter:
<button type="submit" name="action" value="save">Save</button>
Build adds two reserved successful controls and matching Player-only data attributes:
<form
method="post"
action="/lesson.path"
data-pathmx-form="save"
data-pathmx-action="responses.save"
data-pathmx-action-scope="block"
data-pathmx-action-target="lesson.path#outlier-model"
>
<input type="hidden" name="__pathmx_action_scope" value="block">
<input type="hidden" name="__pathmx_action_target" value="lesson.path#outlier-model">
<!-- rendered Block and Action input controls -->
<button type="submit" name="action" value="save">Save</button>
</form>
The exact generated matrix is:
| Mapping owner | __pathmx_action_scope | __pathmx_action_target |
|---|---|---|
| Source | source | stable Source ID |
| Block | block | stable Block ID |
The server parser requires exactly one non-empty string for action,
__pathmx_action_scope, and __pathmx_action_target. It validates the scope,
checks that a Source target equals the routed Source ID, and constructs:
const mapping: PathMXActionMappingSelector =
scope === "source"
? { type: "source", localName: action }
: { type: "block", blockId: target, localName: action }
The two reserved controls and action are transport metadata. They are removed
before the remaining fields become PathMXActionInput for the Action parser.
The browser is allowed to provide the selector because it is only a lookup request. It cannot provide the qualified Action ID or canonical Action target. The trusted Build projection resolves both, and authority checks the result. Changing a hidden Block ID can at most request another mapping already attached to the same routed Source; it cannot invent a mapping, bypass Availability, or change Actor authority.
The matching data-pathmx-* attributes exist for Player projection and
transient form identity. The server does not receive or trust DOM attributes.
Build Index
The Build adapter stops flattening all local names into one Source map. Its logical index is scoped:
type IndexedSourceActions = Readonly<{
source: ReadonlyMap<string, IndexedAction>
blocks: ReadonlyMap<string, ReadonlyMap<string, IndexedAction>>
}>
type PathMXBuildActorSourceIndex = Readonly<{
// existing members
actionsBySource: ReadonlyMap<string, IndexedSourceActions>
}>
Indexing rules:
- validate the Source
actionsmap intosource; - create one map per stable Block ID;
- validate each Block's
actionsmap into that Block map; - never merge local names across owners;
- diagnose duplicate or invalid Block IDs through the existing Source model;
- resolve an exact selector against exactly one owner map.
The resulting lookup is O(1) by Source ID, scope/Block ID, and local name.
No DOM scan, Source parse, or network access occurs during Actor resolution.
Invocation Lifecycle
- Build renders the mapping owner into a normal form with scoped metadata.
- Native or Player-projected submission sends the local name and mapping scope/target locator.
- The server validates and strips reserved transport fields.
host.actreceives the scoped mapping selector.resolveActionreads the current Build projection and resolves the exact Source or Block mapping.- The Host looks up the trusted Action Definition, checks authority and Availability against the resolved canonical target, parses Action fields, and admits the Run.
- The Journal continues to record
localActionand canonicaltarget; it does not need to persist the redundant selector.
Invocation ID idempotency is unchanged because the sealed request already includes the resolved canonical target and local Action.
Errors
| Condition | Outcome |
|---|---|
| missing/repeated/invalid reserved form metadata | 422 invalid-request; no Run |
| Source selector target does not equal routed Source | 422 invalid-request; no Run |
| selected Block does not exist in current Source | unknown-action; no Run |
| local name absent from selected owner | unknown-action; no Run |
| mapping value invalid | existing invalid Action Mapping rejection; no Run |
| Source/Block mapping changed after render | current resolution wins; stale or different mapping is checked normally |
| selected target is unauthorized/unavailable | existing typed Host rejection; no Run |
Missing mappings use one outward unknown-action result so the selector does
not become an existence oracle for hidden Block structure.
Migration
This correction lands atomically across:
- Actor public contracts and Host resolution call;
- Build Actor index and adapter;
- generated Source/Block forms;
- Bun server normal-form parser;
- Player transient form keys and Action Affordance discovery;
- current task/question form fixtures and direct
host.actcallers; and - accepted Actor/form docs and domain language.
Do not retain the flat actionsBySource local-name lookup, infer Block scope
from Action input field names, generate unique local names, or accept old forms
without scope metadata. Those approaches preserve the ambiguity this contract
removes.
Verification Contract
Focused tests must cover:
- the same local name on two Blocks resolving to two canonical targets;
- the same local name at Source and Block scope;
- invalid mappings isolated to their owner rather than poisoning another owner's valid local name;
- exact generated hidden controls and matching Player data attributes;
- native and Player-enhanced normal form parsing with reserved fields stripped from Action input;
- missing, repeated, malformed, stale, and unknown selectors;
- crafted selectors failing to bypass Action Availability or authority;
- Invocation ID retry equality after scoped resolution; and
- task, question, and two durable-response Blocks in one real Source.
Maintainer Decision
Accept or revise this exact correction:
PathMXActionMappingSelectorwith Source and Block variants;PathMXActionSubmission.mappingand scopedresolveActioninput;- reserved generated form scope/target controls plus matching Player data;
- owner-scoped Build indexes that allow repeated local names across Blocks;
- trusted mapping resolution still owning qualified Action ID and canonical target; and
- atomic greenfield migration with no flattened compatibility path.