hecks

Implicit append fields

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).

Motivation

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.

Algorithm

For each mutation in the command’s own declared mutations where mutation.op is append:

  1. Resolve the mutation’s TARGET field (e.g. :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.
  2. Take that attribute’s own element TYPE NAME (the bare text 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.”
  3. From the mutation’s own field hash (e.g. { 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.
  4. If self_ref_fields is empty, do nothing.
  5. Partition 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.
  6. Compute 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.
  7. Remove every member of 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.
  8. Build the resolved GROUP, in 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).
  9. Insert the whole group, in order, at 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.

Qualifies / does not qualify

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

Known limitations

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.

Reference implementation

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).

Reference mirror

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.