Bare, self-referential fields inside an append: mutation
(sets :list, append: { field: :field, ... }) import the LIST FIELD’S OWN
ELEMENT CONSTRUCT’s attribute of the same name — one hop deeper than
implicit-command-attributes.md, whose rule
alone can’t reach this case (the aggregate itself never stores the field;
only the list’s own element entity or value object does).
Same redundancy as implicit-command-attributes.md, one level down: a
command building a new list element via append: still needed to redeclare
each field’s type locally, byte-identical to whatever the list’s own element
construct already declares. Real evidence: Account.Credit (banking) builds
a LedgerEntry via sets :ledger, append: { amount: :amount, narrative:
:narrative, direction: {...} }; LedgerEntry (an entity) declares
attribute :narrative, Narrative itself, and Credit used to redeclare it
verbatim purely to feed the append. The self-hosted meta-domain’s own
Keyword/Argument seed-row commands are a more extreme case — EVERY field
of both (8 and 13 respectively) used to be locally redeclared this way.
For each mutation in the command’s own declared mutations where
mutation.op is append:
:ledger) on the OWNER (the
aggregate or entity this command is declared on). It must be a
list-typed attribute (list_of(...)); if not, or if it isn’t found at
all, do nothing.list_of(X)
names, e.g. "LedgerEntry") and look it up among the owner’s own
constructs — its declared value objects first, then its declared
entities (mirroring AggregateBuilder#command’s own @value_objects +
closed_sets + @entities pool order). If no construct with that name is
found, do nothing — see “Known limitations.”{ amount: :amount, narrative:
:narrative, direction: { value: "credit" } }), collect every field
whose VALUE is a bare symbol equal to its own KEY, in the hash’s own
iteration order — narrative: :narrative qualifies, direction: {
value: "credit" } does not (its value isn’t a symbol at all), and
amount: :adjustment would not (value ≠ key). Call this ordered list
self_ref_fields.self_ref_fields is empty, do nothing.self_ref_fields into those the command ALREADY declares
locally (present, in whatever order they occur in the command’s own
attribute list) and those it doesn’t. If every field is already present,
do nothing — fully explicit, nothing to resolve.anchor: the MINIMUM index among present’s members in the
command’s own attribute list, or (if present is empty — every field in
the group is being resolved) the current length of the attribute list —
i.e. append the whole group at the end.present from the command’s own attribute list.
Because anchor is by construction the minimum index among the removed
set, nothing BEFORE anchor shifts as a result.self_ref_fields’ own order: for each
field, either its already-present attribute object (unchanged) or the
element construct’s own attribute of that name (looked up fresh, same
verbatim-import as implicit-command-attributes.md step 5 — full type/
pattern/optional/admits, not just the name).anchor.Why step 6–9 matter, not just “append the missing ones”: the exported
IR is array-order-sensitive — an attribute’s position in the list is part
of what the IR carries, not incidental. A naive attributes << resolved
(append at the end, ignoring existing position) only reproduces the
original order when the missing field happens to already be last. Real
evidence from building this rule: Keyword#was and Argument#variadic
(both genuinely LAST in their own append hash) round-tripped correctly
under a first, naive append-only implementation; every OTHER field in the
same two hashes did not, and was silently placed in the wrong position —
caught only by a codemod’s own reboot-and-diff safety net, not by the
insertion logic itself. The group-anchor algorithm above is the fix.
| Command body (append hash) | Outcome |
|---|---|
{ narrative: :narrative }, command declares nothing locally, element declares attribute :narrative, Narrative |
Imports attribute :narrative, Narrative |
{ amount: :amount, narrative: :narrative, direction: { value: "credit" } }, :amount still explicit (different type than the element’s own), :narrative not declared |
Only :narrative resolves, inserted immediately adjacent to :amount’s existing position — group stays contiguous |
{ word: :word, context: :context, ... } (8 fields), NONE declared locally |
All 8 resolved as one contiguous group, in hash order, appended together (no present member to anchor against) |
direction: { value: "credit" } |
Never a candidate — the value is a literal hash, not a bare symbol |
amount: :adjustment |
Never a candidate — value (:adjustment) ≠ key (amount) |
| Element construct not found (see limitation below) | Nothing resolves; the command’s own explicit declarations (if any) are all that’s left |
RESOLVED. AggregateBuilder/EntityBuilder used to build entity/
command/query immediately, inline, the instant that DSL line executed —
so a command’s own resolution could only ever see whatever the owner had
declared SO FAR, textually, never anything declared later in the same
block. entity/command/query now queue a descriptor instead of
building immediately; each owner’s own #build drains the queue (entities
first and fully, then commands, then queries) only after its whole block
has finished — the same “collect everything, then run cross-referential
work in a second pass” move BluebookBuilder already made one level up
(build every aggregate in the chapter first, then validate query hops/
projected fields/reference cycles against the complete set), extended one
level down. command "Handler"/command "Dispatch"/command "Member" in
the self-hosted meta-domain (lib/hecks/language/bluebook/,
reaction.bluebook/shape.bluebook) — declared before the entity
they create — now resolve correctly, verified directly: with their
explicit attribute lines removed, each imports the right attribute from
its own entity, in the right position. Their corpus text hasn’t actually
been migrated (that’s separate follow-up work, not required for the
restructure itself); they still carry explicit declarations today, which
is harmless — an explicit local declaration always wins over the resolved
one regardless.
The SAME fix also surfaced (and fixed) a previously-hidden bug in
identified_by’s own single-field-value-object auto-unwrap, one level
over: spec/fixtures/model_check/lifecycle_findings.bluebook declares
entity "Part" with identified_by :serial / attribute :serial,
Serial, and declares value_object "Serial" (a single-field VO) AFTER
the entity, later in the same aggregate — Ruby now correctly resolves
identity_paths to ["serial.value"] (the documented unwrap rule) where
it silently produced the wrong ["serial"] before, because Serial
wasn’t visible yet under the old eager snapshot. Not a hypothetical: this
was a real, live, previously-undetected bug in real fixture output, caught
only by this restructure’s own verification against spec/
parser_parity_spec.rb.
Hecks::Bluebook::DSL::CommandBuilder#resolve_append_fields! and
#element_type_for (lib/hecks/bluebook/dsl/command_builder.rb) — the
owner’s own constructs are threaded in as owner_constructs:, set by
AggregateBuilder#command (@value_objects + closed_sets + @entities) and
EntityBuilder#command (@owner_value_objects + @entities).
rust/parser/src/parse/command.rs, resolve_append_fields +
element_type_attributes — owner_value_objects: &[ir::ValueObject] and
owner_entities: &[ir::Entity] threaded through parse_body from
rust/parser/src/parse/aggregate.rs and rust/parser/src/parse/entity.rs,
“declared so far” exactly like Ruby — which is why the three meta-domain
exceptions above resolve to None in Rust too, with no special-casing
needed; it falls directly out of the shared “so far” semantics. Verified
byte-identical against the real corpus via spec/parser_parity_spec.rb,
including banking and bluebook_language specifically.