Skip to content

docs(audit): a capability belongs to the object class it acts on, not the verb that first needed it - #325

Open
stellarfeline wants to merge 1 commit into
mainfrom
docs/capability-ownership-audit
Open

docs(audit): a capability belongs to the object class it acts on, not the verb that first needed it#325
stellarfeline wants to merge 1 commit into
mainfrom
docs/capability-ownership-audit

Conversation

@stellarfeline

Copy link
Copy Markdown
Owner

What this is

An audit, not a fix. No behaviour changes; no player-facing byte moves. The
sealed_hint/shortcut-door lift belongs to another worker and this PR does not
touch that surface.

What the audit found

The owner's instance was close-gate.sealed_hint. Checking it against the code
inverted the diagnosis twice:

  1. It is not a capability keyed to the wrong parent. It is a private
    re-implementation, inside one verb, of EnvTrigger{on:use} + effects

    which the DSL already exposes in full, with the whole effect vocabulary, flag
    gating and once.
  2. The root cause is one rung lower still: EnvTrigger binds an interaction
    body to a POINT at a cell, and a seal, a door and a boulder are VOLUMES.

    close-gate is the only site in the compiler that binds to an object's shape
    (one body per shell cell). Rather than widening the general mechanism, the
    general mechanism was taught to ride the private one
    (trigger_rides_seal, npc_hitbox_trigger_tags).

So the fix is neither a second bespoke field nor a new stage-5 section — the
latter would be a third mechanism, strictly weaker than EnvTrigger.

Four row shapes, 24 rows, each with file:line evidence, severity, and the owner's
test answered explicitly (does this primitive encode a MECHANISM, or a DESIGN
DECISION about what the mechanism is for?
). Full ledger:
docs/notes/capability-ownership-audit.md.

The sharpest finding, and it is a live latent bug

shortcuts[].on_unlock is a sixth effect root that no enumeration knows
about.
It is a Vec<QuestEffect> hanging off a stage-5 struct — structurally
identical in kind to traps[].payload, which is root R4 — and emission really
lowers it (emit.rs:5043). It is not an EffectRootKind variant and not in
nested_effect_lists. Every walk that inherits the five roots skips it: a
narrate inside it is never l10n-inventoried; a set-flag inside it is invisible
to the flag model and to emit::declared_flags.

This is exactly the defect PRs #301/#302/#321 closed thirteen times, still live in
the one root the enumeration does not contain. check-effect-roots.py cannot see
it: that gate greps for the five roots it knows, so a sixth is invisible to it
by construction. Zero live campaign usage is the only reason it has not shipped.

Two more of the same family, both verified directly:

  • DW0473 (unavoidable lethal damage) walks 2 of 5 roots, so a
    damage-players inside a traps[].payload is invisible to it — and spec-0022
    made trap payloads the intended home for exactly that. Twelve further
    hand-rolled walks still miss roots.
  • emit::affordances claims totality in its own comment ("the list is the
    definition of the class … which is what makes the proof total rather than a spot
    check") and is a hand-enumerated list of four kinds, missing five.

What CI now proves

tools/check-capability-ownership.py, added as a step of the existing
docs (local link check) job
— a step, not a job, because every job name is a
required status context and a new one would deadlock branch protection until it
was added there first.

Five ledgers, each an allowlist carrying a reason per entry. Each prints a binding
count and fails on a zero binding, because a gate that matched nothing is
vacuous, not a pass.

OK: check A interaction bodies — 129 files examined, 9 matched, 0 unjustified.
OK: check B baked player strings — 129 files examined, 5 matched, 0 unjustified.
OK: check C structural twins — 61 DSL structs examined, 2 matched, 0 unjustified.
OK: check D modifier holes — 93 (enum, field) pairs examined, 6 matched, 0 unjustified.
OK: check E effect bundles — 10 Vec<QuestEffect> fields examined, 10 matched, 0 unjustified.

Green here is not a clean bill: most entries are OPEN FINDINGS with a named
lift. The gate's job is that none can be added or removed in silence.

Demonstrated firing on the live instances

Removing seal_fns and SEAL_HINT_DEFAULT from the ledger — i.e. simulating
sealed_hint being introduced today:

FAIL: crates/compiler/src/emit.rs:4887 — `fn seal_fns` summons a `minecraft:interaction` body that no ledger entry justifies.
FAIL: check A interaction bodies — 129 files examined, 9 matched, 1 unjustified.
FAIL: crates/compiler/src/plan.rs:147 — `SEAL_HINT_DEFAULT` bakes player-facing English into one code path.
FAIL: check B baked player strings — 129 files examined, 5 matched, 1 unjustified.

Removing on_unlock from check E's ledger:

FAIL: crates/dsl/src/stages.rs:1703 — `on_unlock: Vec<QuestEffect>` is an effect bundle no enumeration is known to reach.
FAIL: check E effect bundles — 10 Vec<QuestEffect> fields examined, 10 matched, 1 unjustified.

Check E is the answer to "why did the existing gate not catch this": it asks the
inverse question — every bundle in the DSL must be claimed by some enumeration —
which does not require knowing the roots in advance.

Recommended sequence (honest scope)

This is materially more than one round. The proposed cut is steps 0–3 as one
milestone
— they block content today and none forces campaign adoption
holding the rest, because step 5's shape depends entirely on step 1's outcome.

  1. Bring on_unlock inside an enumeration (the Ambush::to_trigger desugar
    pattern, not a sixth root). First and alone.
  2. Widen EnvTrigger.at from a point to an anchor's shape, plus a narrate
    actionbar style — one lift; the prerequisite for the sealed_hint work.
  3. Lift requires_flags/forbids_flags to every QuestEffect (16 of 26 today;
    no staging or souls verb can be branch-gated).
  4. Close the effect-root walks; DW0473 first and alone.

Adoption cost is far lower than expected: on_unlock, disarm, via,
sets_flag, payload, sealed_hint, crush, bonfire, shortcuts,
timed_gates, traps and eight more have zero live usages across all
tracked campaign content.

Two things recorded but not judged

  • crates/grammar/src/library/bell/ (1131 lines) holds one campaign's zone
    programs inside the engine crate — by the owner's principle the largest open
    instance. Plausibly deliberate as the grammar back end's first production
    workload. Owner's call; no move proposed.
  • nobodys-cave and the-drowned-bell exist only as untracked build outputs
    with no source in git
    , so any DSL change orphans them permanently.
    the-drowned-bell is the campaign whose playtest produced DW0420/DW0421.

Process root cause

ADR-0015 already sets promotion gates (second-campaign, machine-proof). Four of
the fields audited were introduced by a task, not a spec, and recorded no gate
:
sealed_hint (#142), missing_item_hint (v0.7, no spec, and absent from the
DW0141 list that claims to be exhaustive), timed-gate disarm (#184), and
on_unlock (one parenthetical in spec-0016 §2). The gate exists; the task route
bypasses it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL

… the verb that first needed it

Audit only — no behaviour changes, no player-facing byte moves.

`close-gate.sealed_hint` turned out not to be a capability keyed to the
wrong parent. It is a private re-implementation, inside one verb, of
`EnvTrigger{on:use} + effects` — which the DSL already exposes in full.
The root cause is one rung lower: `EnvTrigger` binds an interaction body
to a POINT at a cell, and a seal, a door and a boulder are VOLUMES, so
every volume object grew its own fleet.

Ledger of 24 rows in docs/notes/capability-ownership-audit.md, each
answering the owner's test — does this primitive encode a MECHANISM, or
a DESIGN DECISION about what the mechanism is for — and each carrying
its severity and version-adoption cost.

Adds tools/check-capability-ownership.py as a step of the existing
`docs (local link check)` job (a step, not a job: every job name is a
required status context, and a new one would deadlock branch
protection). Five ledgers, each stating a binding count and failing on
a zero binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant