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.

Authored Source

Local Action names are unique within their authored owner, not across an entire Source.

<!--
type: model
id: outlier-model
actions:
  save: responses.save
-->

<!--
type: classification
id: statistic-groups
actions:
  save: responses.save
-->

The two save mappings are distinct because their owners are distinct stable Blocks. A Source-level save may also exist; it is selected as a Source mapping and does not collide with either Block mapping.

No new authored field is introduced. The actions map and qualified Action IDs retain their accepted meaning.

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
Sourcesourcestable Source ID
Blockblockstable 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:

  1. validate the Source actions map into source;
  2. create one map per stable Block ID;
  3. validate each Block's actions map into that Block map;
  4. never merge local names across owners;
  5. diagnose duplicate or invalid Block IDs through the existing Source model;
  6. 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

  1. Build renders the mapping owner into a normal form with scoped metadata.
  2. Native or Player-projected submission sends the local name and mapping scope/target locator.
  3. The server validates and strips reserved transport fields.
  4. host.act receives the scoped mapping selector.
  5. resolveAction reads the current Build projection and resolves the exact Source or Block mapping.
  6. The Host looks up the trusted Action Definition, checks authority and Availability against the resolved canonical target, parses Action fields, and admits the Run.
  7. The Journal continues to record localAction and canonical target; 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

ConditionOutcome
missing/repeated/invalid reserved form metadata422 invalid-request; no Run
Source selector target does not equal routed Source422 invalid-request; no Run
selected Block does not exist in current Sourceunknown-action; no Run
local name absent from selected ownerunknown-action; no Run
mapping value invalidexisting invalid Action Mapping rejection; no Run
Source/Block mapping changed after rendercurrent resolution wins; stale or different mapping is checked normally
selected target is unauthorized/unavailableexisting 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:

  1. Actor public contracts and Host resolution call;
  2. Build Actor index and adapter;
  3. generated Source/Block forms;
  4. Bun server normal-form parser;
  5. Player transient form keys and Action Affordance discovery;
  6. current task/question form fixtures and direct host.act callers; and
  7. 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:

  1. PathMXActionMappingSelector with Source and Block variants;
  2. PathMXActionSubmission.mapping and scoped resolveAction input;
  3. reserved generated form scope/target controls plus matching Player data;
  4. owner-scoped Build indexes that allow repeated local names across Blocks;
  5. trusted mapping resolution still owning qualified Action ID and canonical target; and
  6. atomic greenfield migration with no flattened compatibility path.