feat(morfo): allowedTargets — el morfo declara a que partes puede viajar el estampado

El runtime deja que el proveedor redirija el estampado por emision
(fallbackTarget GANA sobre el ref del target declarado). Es comportamiento
correcto para partes repetidas — el dia pulsado del Calendar, la pagina
clicada de Pagination — pero el morfo no decia nada: la declaracion leia
target: provider mientras cada emision real caia en day / item /
prev-button. Un pack escrito desde el morfo apuntaba a un nodo que nunca
lleva el estampado, y nada podia distinguir ese error de esta redireccion
legitima. La auditoria 2026-08-05 midio el error (S-12: TextArea 6x/13x
sobre su firma escrita); el censo nuevo encontro 12 reglas identicas en
forma que estaban BIEN. Solo el morfo puede separarlas, asi que el morfo
lo dice.

allowedTargets es el simetrico exacto de allowedFamilies en el otro eje:
el target canonico sigue siendo el default que el runtime resuelve; la
lista declara las demas superficies donde una emision puede caer.
Invariantes en schema.ts: parte inexistente o auto-listado = error de
compilacion del morfo. Declarado en calendar (5 superficies), pagination
(5), rating-group, toolbar y tree-view (branch) — cada entrada verificada
contra sus emisores reales, no contra el pack.

Pendiente declarado: renombrar fallbackTarget -> targetOverride (nombre
honesto para una opcion que gana) va en commit aparte — 193 apariciones
en 63 ficheros esconderian este contrato dentro de un diff de renombrado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 866089a407
commit f7a7564b39

@ -590,6 +590,17 @@ Field rules:
- **`semantic.target`** — the `partRef` whose DOM element receives the
`data-event-*` attrs during the visual hold. Lives inside `semantic`
per the doctrinal shape (was at event-level pre-2026-05-08).
- **`semantic.allowedTargets`** — optional list of `partRef`s the provider
may redirect the stamp to at trigger time (the `fallbackTarget` trigger
option wins over the registered target ref). The exact counterpart of
`allowedFamilies` on the target axis: `target` stays the default the
runtime resolves; this declares the OTHER surfaces an emission may land
on — repeated parts like the pressed `day` of a Calendar or the clicked
`item` of a Pagination. Declaring it is what lets `pack-census.test.ts`
tell a legitimate redirection from drift: a sema rule may select the
declared target or a part listed here, and nothing else (the TextArea
class of bug — rules aimed at a part the stamp never visits — ran 6×/13×
above its written gain for months without any guard seeing it).
- **`semantic.sequence`** — when the perceptual signal happens
relative to the structural state change. Default `'pre'` preserves
the runtime semantics where the signal completes before the commit.

@ -37,7 +37,18 @@ export const calendarMorfo = {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
sequence: 'post',
// The provider hands the surface that CAUSED the navigation — the
// arrow pressed, the select changed, the day whose month spilled
// over — so the frame change is felt where it was asked for. The
// provider is the target only when nothing concrete caused it.
allowedTargets: [
v.partRef('prev-button'),
v.partRef('next-button'),
v.partRef('month-select'),
v.partRef('year-select'),
v.partRef('day')
]
}
}
],

@ -23,7 +23,17 @@ export const paginationMorfo = {
verb: 'set',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
sequence: 'post',
// One event, five surfaces: the provider hands the control that
// set the page so the commit is felt under the finger that asked
// for it, not on the wrapper.
allowedTargets: [
v.partRef('first-trigger'),
v.partRef('prev-trigger'),
v.partRef('item'),
v.partRef('next-trigger'),
v.partRef('last-trigger')
]
}
}
],

@ -18,7 +18,10 @@ export const ratingGroupMorfo = {
verb: 'set',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
sequence: 'post',
// The rated item carries the commit: a repeated part, so the
// provider hands the exact star that was chosen.
allowedTargets: [v.partRef('item')]
}
}
],

@ -18,7 +18,10 @@ export const toolbarMorfo = {
verb: 'toggle',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
sequence: 'post',
// The toggled item of a group is a repeated part; the provider
// hands the one that flipped.
allowedTargets: [v.partRef('group-item')]
}
}
],

@ -22,7 +22,11 @@ export const treeViewMorfo = {
verb: 'select',
target: v.partRef('item'),
intent: 'affirm',
sequence: 'post'
sequence: 'post',
// A branch is selectable too, and it is a different part from a
// leaf: the provider hands whichever node was chosen, which is
// why the pack carries a rule for each.
allowedTargets: [v.partRef('branch')]
}
},
{

@ -266,7 +266,8 @@ const eventSemanticSchema = union(
verb: optional(string()),
sequence: optional(sequenceSchema),
persistence: optional(persistenceSchema),
allowedFamilies: optional(array(semaFamilySchema))
allowedFamilies: optional(array(semaFamilySchema)),
allowedTargets: optional(array(partRefSchema))
}),
object({
family: semaIntentExpectedFamilySchema,
@ -276,7 +277,8 @@ const eventSemanticSchema = union(
verb: optional(string()),
sequence: optional(sequenceSchema),
persistence: optional(persistenceSchema),
allowedFamilies: optional(array(semaFamilySchema))
allowedFamilies: optional(array(semaFamilySchema)),
allowedTargets: optional(array(partRefSchema))
})
);
@ -635,6 +637,19 @@ function validateInvariants(morfo: Morfo): void {
);
}
for (const allowed of event.semantic.allowedTargets ?? []) {
if (!kebabs.has(allowed.target)) {
throw new MorfoInvariantError(
`event "${event.name}" allows unknown target part "${allowed.target}"`
);
}
if (allowed.target === event.semantic.target.target) {
throw new MorfoInvariantError(
`event "${event.name}" lists its own target "${allowed.target}" in allowedTargets — the canonical target is always allowed`
);
}
}
if ('intent' in event.semantic && typeof event.semantic.intent === 'object') {
if (event.semantic.intent.supported.length === 0) {
throw new MorfoInvariantError(

@ -449,6 +449,33 @@ export type MorfoEventSemantic = (
* channels: ['sound']
*/
channels?: readonly SemaChannelId[];
/**
* Parts the provider may redirect the STAMP to at trigger time, on top of
* the canonical `target`. The exact counterpart of `allowedFamilies`, on
* the other axis: `allowedFamilies` declares the families a provider may
* concrete to, `allowedTargets` declares the parts it may land on.
*
* WHY IT EXISTS. `runtime.trigger(name, { fallbackTarget })` lets a
* provider point at the exact interaction surface of a repeated part — the
* pressed day of a Calendar, the clicked page of a Pagination — and the
* option WINS over the registered ref of the declared target. That is
* correct behaviour and every one of those components needs it. What was
* missing is that the morfo said nothing about it: the declaration read
* `target: v.partRef('provider')` while every real emission landed on
* `day` / `item` / `prev-button`, so a sema pack written from the morfo
* aimed at a node that never carries the stamp — and nothing could tell
* the difference between that mistake and this legitimate redirection.
* The 2026-08-05 audit measured the mistake (TextArea: two rules on
* `provider` while the stamp fell on `input`, running 6× and 13× louder
* than written); the census found 12 more rules that looked identical and
* were RIGHT. Only the morfo can tell them apart, so the morfo says it.
*
* The canonical `target` stays the DEFAULT the runtime resolves when no
* override is passed; `allowedTargets` never changes resolution. It is a
* contract, read by `pack-census.test.ts` — a pack rule may select the
* declared target or any part listed here, and nothing else.
*/
allowedTargets?: readonly PartRef[];
/**
* Per-event signature override (resolution layer 3). Applied AFTER
* family.base + intent.deltas and BEFORE app-level cascade rules. Use

Loading…
Cancel
Save

Powered by TurnKey Linux.