Commit Graph

213 Commits

Author SHA1 Message Date
funman300 6746c75302 docs(fifa17): RE-backed native client-fix design for empty My Packs
Investigation + design only (no client/backend/binary changes, no live
Store experiment). Reconfirmed CardsDLL_Win64_retail.dll (4706a881..,
unpacked) against a freshly rebuilt Ghidra project on .105; FIFA17.exe
(29c31cef..) is Denuvo-packed so the Scaleform decision is unreadable.

PART II added to docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md:
- Native category path traced: FUN_18007dab0 (store render, RVA 0x7dab0)
  reads screen+0x290; My Packs funnels through FUN_1800147f0 (0x147f0) ->
  FUN_180014420 (0x14420, NULL on ordinal miss) -> crash MOV [RDX+0x8] at
  0x14882 ([NULL+0x48]), matching the Exp-B minidump. Tab->ordinal map
  FUN_180014580 (0=mypacks..5=special); category 0 = list-all (Browse).
- Unopened-pack count is a data-manager singleton (vtbl[0x4d8] get /
  [0x4e0] set), reachable from the store resolver.
- Vehicle: existing openfut-hook -> version.dll proxy (already deployed);
  reuse ssl_patch signature-scan + connect_hook inline detour. No new loader.
- Preferred strategy A: entry-hook FUN_18007dab0; when the requested
  category is My Packs and unopened count==0, force screen+0x290=0 (Browse).
  Removes crash + fake 65534 tile + dialog + nav gate; count>0 untouched.
- Ranked B (resolver NULL fallback, higher risk) and C (null-guard, crash-only).
- Build guard: module gate + SHA/PE + signature scan; unknown build -> no
  patch, backend sentinel remains fallback.
- First experiment design (needs a later, separately-authorized backend
  empty-no-sentinel test mode) + client rollback (config flag / dll swap).
- Keep backend 65534 sentinel deployed until strategy A is verified.

Describes the FIFA 17 client/data model only; not OpenFUT Core assumptions.
2026-08-13 01:48:58 +00:00
funman300 b2697b13dc docs(fifa17): establish verified card taxonomy
Single source of truth for FIFA 17 FUT card families, reconciled against
the authoritative shipped fcc_*.json + staff tables (verified byte-identical
between .105 and this repo, 36/36 sha256).

- docs/CARD_TAXONOMY.md: family -> table/rowcount/subtype/carddbid/cardassetid,
  with OBSERVED/INFERRED/HYPOTHESIS/UNKNOWN labels. Corrects four superseded
  claims (chem styles are 250-273 not 91-136; 6300/6400xxx are kits not badges;
  5004xxx misc and 8010xxx league logos exist). Manager-league precision kept
  distinct: shipped table 300-340 (41 rows) vs client enum 300-341 (341 defined,
  unshipped). Club-item wire subtype->family mapping preserved as UNKNOWN.
- docs/evidence/fifa17-recon/table-hashes.sha256: 36-file provenance manifest
  (31 fcc_*.json + 5 staff tables), combined hash 10f239ad...

Describes the FIFA 17 data/client model only; not OpenFUT Core assumptions.
2026-08-13 01:31:22 +00:00
funman300 e8ee6c34e7 docs(fifa17): record empty My Packs client contract
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up.

Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
2026-08-13 01:08:47 +00:00
funman300 f42279f869 fix(fifa17): keep empty My Packs group client-safe
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".

Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via the movie's CATEGORY_ID -> screen+0x290; no server field gates it). CardsDLL FUN_1800147f0 then dereferences the resolved group with no null guard, so an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.

65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).

Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
2026-08-13 01:08:37 +00:00
funman300 54ad9e8f79 docs(state): Slice 8 — real-data staged retail A/B PASS
Records the operator-assisted live A/B on the REAL imported club (1949/1962, 13
Legends deferred): real club render, clean pagination, Special filter (1665, 0
base leaked), squad edit persistence + cold relaunch, and rollback->Python->Rust
re-enable. Documents the three real-data fidelity fixes (versioned resourceId
e187cd4, rareflag 626c972, rare=SP filter 6f16a23) and the nation/league/team
model correction (44fcf24). Marks PRODUCTION NOT READY pending .105 Legends name
data for the 13 deferred assets. Aggregate counts only; no private identity.
2026-08-12 21:37:42 +00:00
funman300 6f16a231fc fix(fifa17): implement rare=SP 'Special' club filter via rareflag
The club search 'Quality = Special' sends rare=SP, which was a deliberate no-op
('semantics UNKNOWN'), so it returned every card — base golds included. The
rareflag work now grounds it: a special is rareflag > 1 (base rare = 1),
evidence-backed by the FIFA17 taxonomy + the observed profile (base Ronaldo/Messi
rareflag 1; their informs 11/24).

rareflag lives in the FIFA catalog, not Core, so Core cannot filter it:
- map_to_core: rare=SP now sets CoreOwnedQuery.special (host-applied), not
  'unsupported'; any OTHER rare value stays unsupported. special is NEVER a Core
  /collection param. is_special_rareflag(rf)=rf>1 lives in the adapter.
- handle_club special path: fetch all items matching the OTHER filters (offset/
  limit stripped), shape (resolves rareflag), then special_filter_page() keeps
  rareflag>1 and paginates the FILTERED set locally (start/count over specials,
  not Core's unfiltered page) — no base leakage, no post-pagination drops.

Verified live on the real staged club: rare=SP -> 1665 items (=1949-284 base),
rareflag distribution all >1, zero base leaked; pagination page0==full[0:50],
page1==full[50:100], no overlap. Tests: adapter map rare=SP->special, unknown
rare stays unsupported, is_special_rareflag predicate; host special_filter_page
filter+paginate. adapter 113 + host 25 + importer 25 green; clippy -D clean.
2026-08-12 21:25:18 +00:00
funman300 626c972232 fix(fifa17): carry observed rareflag so special cards render as specials
shape_item hardcoded rareflag=1, so all 1949 cards shaped as basic rare gold
regardless of type; informs/specials lost their card art. The dev fixture is
base-only, so this was invisible until the real profile (10 distinct rareflag
values) exposed it on .105.

rareflag is definition-level FIFA identity metadata OBSERVED from the profile
(the raw wire integer, never a guessed marketing label), so it lives in the
FIFA catalog like asset_id/version, not in generic Core:
- ObservedDefinition.rareflag + emitted into the production catalog entry.
- Fifa17CardCatalog RawCard/Fifa17CardIdentity gain rareflag (default 1 when a
  base-only catalog omits it, preserving prior wire behaviour).
- Fifa17Identity.rareflag; host resolver populates it from the catalog.
- shape_item emits id.rareflag instead of a hardcoded 1.

Verified on the real staged /club: wire rareflag distribution == source exactly
(0 per-item mismatches across 1949; e.g. rareflag 3 x591, 24 x302, 21 x256).
adapter 111 + host 24 + importer 25 tests green; clippy -D clean. rareflag lives
in the host catalog, not Core, so no re-import was needed.
2026-08-12 21:13:51 +00:00
funman300 44fcf24d92 fix(import-fifa17): nation/league/team are instance metadata, not definition identity
Evidence (resourceId 169193): its 4 owned copies are IDENTICAL in asset/rating/
position/all attributes and differ ONLY in nation/team/league (and those resolve
inconsistently, e.g. team 240 'Atletico Madrid' under league 16 'Ligue 1'). A
player's club affiliation is an instance-time snapshot, not part of the card
DEFINITION identity.

Correct the model (not a special-case): the definition-consistency gate now
compares a DefIdentity projection (asset_id/version/rating/position/attrs/
rareflag) and EXCLUDES nation/league/team. A club-only difference between copies
of one resourceId is no longer a conflict; a real identity disagreement
(rating/position/attrs/asset) still trips it. The definition's display
nation/league/club use the first-observed copy (deterministic; display-only,
never identity). No --defer-conflict allowlist entry is needed for 169193 now.

On the real profile: conflicts 1->0, 169193 reclassified conflict->NoName
(still deferred, unnameable), supported still 1681, deferred instances still 13,
BLOCKERS none without any --defer-conflict flag. 2 new tests (club-only diff is
not a conflict; rating diff still is). crate suite 25 green; clippy -D clean.
2026-08-12 20:58:33 +00:00
funman300 e187cd49a2 fix(fifa17): preserve versioned resourceId on the wire (no special->base collapse)
shape_item emitted resourceId/definitionId = asset_id (base), collapsing every
versioned (special) card onto its base definition on the /club and squad wire.
The dev 32-card fixture is base-only (version 0), so Slice 7 never exposed it;
the real profile (1531 versioned cards) did.

Fifa17Identity now carries resource_id (= (version<<24)|asset_id, == asset_id
for a base card). shape_item emits resourceId/definitionId from resource_id and
assetId/cardassetid from asset_id — versioned and base stay distinct. The host
resolver populates resource_id from the catalog's reconstructed resource_id
(the catalog already parsed version; it was dropped before shaping).

Regression test: versioned 117617092 (v7 of asset 176580) shapes resourceId/
definitionId=117617092, assetId/cardassetid=176580.

Verified on the real staged /club: 1949 items, wire-id set exact, 0
wire->resourceId mismatches, 0 duplicate-multiplicity mismatches vs the source
manifest. adapter 111 + host 24 tests green; clippy -D warnings clean.
2026-08-12 20:46:48 +00:00
funman300 1631d3b1a2 feat(import-fifa17): --apply — recoverable two-store real-profile import
FIFA17-specific orchestration that installs the real profile across BOTH durable
stores (openfut-identity + Core SQLite) recoverably and idempotently, handing
Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer).

apply module:
- owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace;
  identical in BOTH stores (identity core_id AND Core owned_cards.id), so the
  running host's wire->owned reverse lookup resolves exactly what Core stored.
- plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest
  (card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire)
  mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the
  SAME adapter code (parse_squad_put + build_squad_write) the retail-validated
  live squad path uses. Refuses if the report has blockers.
- Two-store protocol (apply): staging gate -> local Core-preflight mirror ->
  identity dry-preflight -> idempotent seed (insert_existing_mapping per instance
  + set_watermark) -> ONE generic Core import transaction (spawned binary) ->
  cross-store post-validation -> completion record. A crash after identity
  seeding re-converges on re-run (idempotent mappings + Core already_imported):
  no cleanup, no reminting.
- gate_staging: deferred players are ABSENT from an import; allowed only for a
  staged run behind --allow-deferred-players-for-staging (never a silent default;
  prints an INCOMPLETE banner). Production requires zero deferred instances.

CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data,
--identity-store, --allow-deferred-players-for-staging). Depends on
openfut-adapter-fifa17 + openfut-identity + uuid(v5).

Proven end-to-end on the real 33068179/CAGE profile (staged): first apply
imports 1949 supported instances (293 base + versioned), 11/11 f433 squad +
opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an
idempotent no-op (already_imported, DB unchanged); staging-not-default refuses
before any write; every OwnedItemId is an opaque UUID; identity wire-id set ==
source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0.

10 new apply tests (determinism, request/mapping/squad translation, blocker
refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/
idempotency, conflict detection, graceful spawn failure). clippy -D warnings
clean; crate suite 23 tests green.
2026-08-12 20:36:34 +00:00
funman300 c71c2a8d33 feat(import): --emit-content (production pack + host catalog + private manifest)
Fold entity resolution (nation/league/club id->name via committed tables,
mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so
the supported set is honest about unresolved entities too. Add an explicit
--defer-conflict <rid> allowlist: a reviewed conflict (169193) defers, any NEW
conflict still hard-fails (defer never becomes a silent conflict suppressor).

--emit-content writes three files, PUBLIC content separated from PRIVATE account
state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_<resourceId>,
base+versioned, tier rarity, profile-derived, no promo labels), a versioned host
identity catalog {card_id:{asset_id,version}}, and a private import manifest
(supported instances' wire ids + deferred set with reasons + preserved watermark
+ target profile + snapshot fingerprint). Emit refuses while blockers exist.

Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned),
9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949
importable instances, watermark 100004617 -> next 100004617, active squad f433
11/11 supported. fmt + clippy -D warnings clean; 13 tests.
2026-08-12 19:41:17 +00:00
funman300 a51947562c feat(import): identity import API + FIFA17 real-profile dry-run importer
openfut-identity:
- insert_existing_mapping(game,kind,core_id,external_id): preserve an existing
  external wire id instead of minting; idempotent for an identical mapping,
  rejects conflicting forward/reverse with IdError::Conflict, persists atomically.
- persisted per-scope allocator watermark (set_watermark/watermark_for) so a
  future mint continues past the source high-water even across burned-id gaps;
  next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk
  format (legacy bare [Row] still loads). +4 tests (10 total).

openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python
profile for a faithful Core import. Enforces disjoint item-class balance;
proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs
versioned never collapse) with a resourceId-group consistency gate (hard-fail on
disagreement, never pick a winner) and honest buildability (roster name +
version formula + metadata, never fabricated); plans owned-instance identity
(preserve Python wire ids, preserve nextItemId watermark); checks active-squad
coverage. --apply/--emit-content refuse to write in this phase. 11 tests.

Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17
consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9
NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a
divergent nation/team/league); 1949 importable player instances, watermark
100004617 -> first new alloc 100004617; active squad f433 fully supported.
fmt + clippy -D warnings clean.
2026-08-12 19:23:19 +00:00
funman300 63f02c4fb1 docs(state): Slice 7 — FUT squad read+write retail-validated on FIFA 17
Record the staged retail A/B: FIFA consumed the Rust squad path end-to-end
(userMassInfo overlay, in-game swap -> squad-replace {"id":0}, formation
f442->f433 persisted to Core, cold relaunch returned the persisted squad),
with Python rollback / Rust re-enable proven by host-log presence. Note the
OPENFUT_DEV_CONTENT_GAMES=fifa17 startup requirement (silent empty /collection
if omitted) as a needed deployment/preflight assertion.
2026-08-12 18:41:24 +00:00
funman300 4fd5ee2608 redirector: a port probe must not forge the signature of a TLS fault
"TLS HANDSHAKE FAILED: ... unexpected EOF" is exactly how the certificate
mismatch presented -- the defect that cost three live gate attempts and was
invisible everywhere else. It is the one line this project has learned to
treat as serious.

A reachability probe forges it for free: TcpStream::connect followed by a
drop opens the connection and closes without sending a byte, and the
acceptor reports that as "unexpected EOF". The launcher's preflight makes
two such probes per run. On 2026-08-12 they produced ten of these lines and
sent a whole session diagnosing a client-side fault that did not exist --
autopatch, ptrace_scope and client_arm.sh were all investigated before the
pairing of the timestamps gave it away.

Classify before the acceptor sees the connection: peek one byte, and treat
EOF-before-any-byte as a probe with its own quiet line. A timeout is
deliberately NOT a probe -- a slow or broken client must still reach the
acceptor and produce a real diagnostic, since misclassifying a fault as
benign would defeat the point.

The counter exists because the test needs it. A probe produces no response
either way, so a test written against client-visible behaviour passes with
the classification deleted; asserting on a count is what makes the mutation
detectable. Verified: all three mutations (drop the classification, treat
undetermined as a probe, treat a speaking client as a probe) are killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:25:21 +00:00
funman300 c7d4b9f753 style(adapter): rustfmt catalog test assertions
Trailing rustfmt reflow of two catalog unit-test assertions (no logic change); clears the adapter dirty state so verify-build-identity.sh passes for the UTAS A/B.
2026-08-12 17:15:14 +00:00
funman300 37c2e5d7ee feat(utas-host): serve GET /squad/active from Core
Migrate the active-squad READ off the Python oracle to the existing Core-backed projector, completing the squad authority (read + write + /squad/list + userMassInfo overlay) on one projector.

- classify: GET /ut/game/<t>/squad/active -> Route::SquadActive. Numeric GET /squad/<n> stays on Python (no Core multi-squad model yet).
- handle_squad_active returns the projector object via user_mass_info_squad(v, persona) — byte-identical to userMassInfo.squad; degrades to an empty overlay on stale/missing/Core-error, never falls back to Python.
- persona: new REQUIRED OPENFUT_PERSONA_ID (non-zero) on HostConfig, injected not baked, must match LSX/Blaze/POW/UTAS identity.
- tests: squad_active parity test; classify updated; README config table + A/B command.

fmt + clippy -D warnings + tests (24 host + adapter) green.
2026-08-12 17:10:04 +00:00
funman300 7dbd878398 test: update mutation-battery anchors after rustfmt reflow
Formatting reflowed two mutation target lines onto multiple lines; update
the battery anchors (adapter #14 kit lookup, host #19 Content-Length push)
to the new unique substrings. Both batteries kill all mutants again
(adapter 14/14, host 20/20).
2026-08-12 04:01:37 +00:00
funman300 c2e2e0d8f2 style: rustfmt squad host + adapter files
Formatting-only. Runs the project formatter over the files authored/edited
this session (host lib+tests, adapter item/squad_ext/squad_projection/mod +
projection test). The intentionally-preserved dirty catalog.rs and
pre-existing /club-era host drift beyond these files are out of scope.
2026-08-12 03:59:15 +00:00
funman300 afc909fd3b fix(fixtures): sanitize lab subnet from committed UTAS captures
The capture sanitizer redacted tokens/device ids but left the lab Host
address (10.10.0.x) in three committed UTAS fixtures, tripping
scripts/check-no-lab-addresses.sh. Replace with an RFC 5737 TEST-NET
address (192.0.2.120) per the guard's own doctrine. Host header is
plaintext metadata only (tests decode body_b64), so no test is affected.
Pre-existing leak (fixtures committed at 0b66662/eb8311a); no lab address
was introduced this session.
2026-08-12 03:59:15 +00:00
funman300 b607ff28cb chore: bump openfut-core gitlink to rustfmt'd squad-ext routes (3084a46) 2026-08-12 03:58:51 +00:00
funman300 cf86d4e425 test(utas-host): squad host mutation battery (20 mutants, all killed)
mutation-battery.sh injects each of the 20 required wrong behaviours into
the committed host (or the adapter it composes) source, runs the one host
test that must catch it, and requires a non-zero exit (killed), reverting
via git after each. Adds a duplicate-definition host round-trip test.

Kills: authz-skipped, authz-loop-empty, failure-masked-as-success,
PUT-as-partial-diff, extension-dropped, stale-accepted, missing-fabricated,
overlay-clobbers-{userInfo,settings,pile}, list-separate-shaping,
captain-as-resourceId, kit-by-slot, client-eval-dropped, host-trusts-fp,
per-slot-N+1, squad/active-to-Rust, duplicate-collapse, stale-content-length,
python-squad-after-failure. 20/20 killed.
2026-08-12 03:11:25 +00:00
funman300 85761390a8 feat(utas-host): FIFA17 squad authority — PUT, /squad/list, userMassInfo overlay
Extend the UTAS migration host to own the squad slice, reusing the exact
production identity path (Fifa17CardCatalog + ExternalIdentityStore) that
/club uses, so /club, /squad/list, userMassInfo and PUT all agree on
wire<->owned identity.

Routing (classified ONCE, no try-Rust-then-Python):
  PUT  …/squad/<n>   -> Rust (numeric id; …/squad/active stays Python)
  GET  …/squad/list  -> Rust
  GET  …/userMassInfo-> Python proxy, ONLY .squad overlaid
  everything else    -> Python verbatim

CoreAccess gains read_squad_ext / replace_squad / all_owned (HTTP to the new
Core /squad/ext + /squad/replace routes).

PUT pipeline: parse -> build_squad_write (reverse-resolve every wire id;
refuse unresolved/duplicate) -> AUTHORIZE every resolved owned item against
the active club (identity resolution is NOT authorization; a valid wire id
owned by another profile is rejected before any mutation) -> Core atomic
replace+extension -> exactly {"id":0}. No Python fallback on failure; no
host-side second extension store; no fingerprint recomputation.

Read path assembles ONE projection input (one read_squad_ext + one batch
all_owned; no per-slot lookup) and runs the single adapter projector. Both
/squad/list and userMassInfo.squad derive from it. Fresh projects; Stale is
never applied; Missing is never fabricated — both are prominent integrity
failures, never served from Python (no split authority). The overlay
replaces only .squad and preserves userInfo/settings/userData/
pileSizeClientData, fixing Content-Length.

Tests: routing, full-replacement + exact ack, unknown/foreign/duplicate item
rejection with Core unchanged, idempotent repeat PUT, coupled read-after-
write (list + userMassInfo agree, real resourceIds + stable wire ids),
overlay field preservation, bounded no-N+1 reads, Stale/Missing integrity.
2026-08-12 03:04:24 +00:00
funman300 4d30d8b3e8 chore: bump openfut-core gitlink to squad-ext HTTP routes (9b2c6b8)
Exposes GET /squad/ext and PUT /squad/replace so the UTAS host can read
and atomically persist the FIFA17 squad canonical+extension state over
HTTP. Core commit sits atop the frozen contract 615c5fd (branch
rust-migration/squad-ext-routes); no domain change. The preserved dirty
openfut-core worktree (eab522a) is intentionally left untouched, so root
status still shows 'M openfut-core' as before.
2026-08-12 02:47:36 +00:00
funman300 0e30980632 style(adapter): elide redundant lifetime in squad projection test helper 2026-08-12 02:31:13 +00:00
funman300 46a81e7a07 test(adapter): squad mutation battery (14 mutants, all killed)
mutation-battery.sh injects each of the 14 required wrong behaviours into
the committed source, runs the one invariant test that must catch it, and
requires a non-zero exit (mutant killed), reverting via git after each.

Kills: kit-by-slot, captain-as-resourceId, chemistry-reconciled,
custom-regenerated, index-derived, stale-accepted, missing-fabricated,
faked-asset-id, duplicate-instance-collapse, PUT-as-slot-diff,
wire-id-in-canonical, projector-bypasses-shared-shaper, schema-version-
ignored, player-state-keyed-by-definition. 14/14 killed.
2026-08-12 02:30:38 +00:00
funman300 e09344490f feat(adapter): single FIFA17 squad projector + fixture round-trip tests
fut::squad_projection is the ONE projector for every squad read shape.
project_squad(canonical squad + Fresh extension + owned items) -> the FIFA
17 squad wire object; user_mass_info_squad and squad_list are envelope-only
wrappers over the same output (no per-endpoint domain model).

Design guarantees exercised by tests:
  - purity / no N+1: consumes a host-assembled input (read_squad_with_ext +
    one batch owned-cards fetch + in-memory card defs); no per-slot lookup
  - shared shaper: every occupied slot is shaped by fut::item::shape_item,
    so squad items and /club items cannot drift
  - Fresh -> full projection; Stale -> never applied (verdict surfaced);
    Missing -> explicit, never fabricated
  - captain projects as the resolved WIRE id (never resourceId); index and
    formation round-trip verbatim; kit follows the player; two owned copies
    of one definition stay distinct

Adds committed sanitized fixtures decoded from the squad session capture
(swap, f433, persisted userMassInfo.squad read, squad/list) and
tests/squad_projection.rs: baseline / swap / formation-change / persisted
read-after-write round-trips asserted by ownership class (canonical,
extension, shadow, derived identity), plus one-projector no-divergence.
2026-08-12 02:28:05 +00:00
funman300 80a8bc4520 feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder
Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned
payload Core stores opaquely alongside the canonical squad. Carries the
FIFA-only wire state that is not Core-canonical:
  - custom[]        opaque 33-int string, round-tripped verbatim
  - squad_type      observed FIFA token
  - kit_numbers     keyed by owned_card_id (kit follows the PLAYER, proven
                    by the swap/formation captures), never by slot/definition
  - manager         opaque item ref (not a squad player; not shaped)
  - kicktakers      opaque role refs; relationship to captain UNKNOWN, so
                    preserved verbatim and never normalized to the captain
  - client_reported chemistry/rating/starRating shadow, never authoritative
from_payload enforces the payload schema version first (distinct from Core's
DB schema); an unknown version is rejected, never coerced.

build_squad_write turns a parsed PUT + host wire->owned resolver into a
canonical ProposedSquad + extension, refusing on unresolved ids or a
duplicate owned item. Identity resolution is explicitly NOT authorization.

Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical
(FIFA-only + shadow fields moved to the extension); the canonical formation
is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that
could not even represent f433) so formation and index round-trip exactly
with no derivation. Bench split is the fixed 23-slot array convention.
2026-08-12 02:19:22 +00:00
funman300 b50e0359f7 feat(adapter): extract shared FIFA17 FUT item-shaping primitive
Move the per-item card shaper (CoreOwnedItem, Fifa17Identity,
ItemIdentityResolver, ShapeStats, shape_item) out of club_response into
fut::item so /club and the upcoming squad projection emit byte-identical
items from one source of truth. club_response keeps only the /club
{itemData:[...]} envelope and re-exports the moved types for API
stability. shape_item is now pub; no behavior change (all /club and
oracle-parity tests unchanged and green).

Adds item-shaper tests: full-field identity mapping and the duplicate
owned-copy invariant (two instances of one definition keep distinct wire
ids, share one asset id).
2026-08-12 02:14:41 +00:00
funman300 58a300c7f4 core: game-scoped opaque squad extension + atomic fingerprint write (submodule 615c5fd) 2026-08-12 01:40:13 +00:00
funman300 eb8311a5ee evidence: FIFA17 squad controlled retail capture (swap/formation/relaunch) sanitized fixtures 2026-08-12 01:16:46 +00:00
funman300 550a59d12c feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted) 2026-08-12 00:53:46 +00:00
funman300 8c1d1ed958 docs(mirror): /club RUNTIME VALIDATED on retail FIFA 17 2026-08-12 00:36:26 +00:00
funman300 fc00b0c6f9 docs(mirror): /club composition proven live (slice 5) 2026-08-11 23:05:29 +00:00
funman300 5276dd2066 feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test 2026-08-11 23:00:08 +00:00
funman300 88da16a11e feat(fifa17): curated dev content pack generator + Core dev seed (submodule 36abd4b) 2026-08-11 22:57:02 +00:00
funman300 3ef3bc32ec feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders 2026-08-11 22:33:40 +00:00
funman300 36fe1caa3f feat(fifa17): deterministic base-card seed generator + full identity catalog
scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data
(pool.json + roster.json + leagues/nations/teams tables) -> the card-definition
identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>),
version 0 (base cards only; resource_id == asset_id). --check mode diffs against
committed output (drift-detection mutation-proven). Provenance embedded.

Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563
base assets. Semantic definition coverage (to /tmp, not committed here): 17,547
resolvable; 16 skipped for missing roster name (reported, never fabricated).

Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801
-> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards
exist', not 'which the user owns'. Core content seeding + dev-owned set next.
2026-08-11 22:19:57 +00:00
funman300 f55c401b6c feat(fifa17): card-definition identity catalog + owned-item wire-id policy
fut::catalog — Fifa17CardCatalog maps a semantic CardDefinitionId to a FIFA 17
render identity (resource_id = (version<<24)|asset_id; version 0 => resource==
asset). Versioned JSON (schema_version=1, game=fifa17); validates schema/game,
rejects asset_id > 24 bits, and rejects two card ids claiming one resource_id.
Unknown definitions resolve to None (callers drop, never fabricate).
Fifa17WireItemIdPolicy carries the owned-item namespace (base 100_000_000,
first id 100_000_001, per the oracle) supplied to the generic store.

Adds serde derive to the adapter. 8 catalog tests; 3/3 mutations killed
(resourceId-drops-version, conflict-detection-off, asset-range-off).
Phase commit 2/5. No card->asset DATA shipped: the synthetic Core catalogue is
unmappable (see seed plan); the loader + format land now, population later.
2026-08-11 21:59:50 +00:00
funman300 b8037b9b22 feat(identity): generic game-scoped external-identity store
openfut-identity: durable, reversible (game_id, entity_kind, core_id) <->
external wire id mapping. Game-independent infrastructure (adapters supply the
numeric policy via base_floor; the store guarantees stable/unique/reversible/
game-scoped/persistent/atomic/explicit). JSON-file backed behind an
ExternalIdentityStore trait (SQLite can drop in later); parking_lot-guarded,
atomic temp+rename persist, rejects a torn reverse-duplicate on open.

Core never learns FIFA integers; only the host/adapter that owns a game
boundary uses this. 6 tests, 4/4 mutations killed (same-id-for-two-items,
lost-on-restart, broken-reverse, dropped-game-scope). Phase commit 1/5.
2026-08-11 21:57:15 +00:00
funman300 c0a3f68ded feat(utas): FIFA17 UTAS migration host + /club adapter mappings
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).

openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).

openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
2026-08-11 21:40:15 +00:00
funman300 04c5043aba utas: My Squad filter corpus — every filter identified, root cause measured
Controlled retail capture, one criterion at a time, cleared between each.
47 transactions. Every filter the My Squad picker sends is now known from
the wire rather than guessed.

Route: GET /ut/game/fifa17/club -- the picker hits UTAS and reuses the
general club-inventory route.

  level=any|gold        quality      lowercase, ALWAYS present
  rare=SP               "Special"    uppercase, OMITTED when off
  position=ST           position     uppercase, omitted when off
  nation=52             entity id    numeric
  league=13             entity id    numeric
  team=5                entity id    numeric, NESTED under league
  sort=desc                          client constant; the UI has no sort control
  start=/count=11       pagination

Two encoding families: short string enums, and numeric FIFA ids. The ids
must never reach Core. Filters compose as plain ANDs in one query --
string and id filters alike -- so each maps independently.

THE ROOT CAUSE IS SELF-AMPLIFYING.

club_route honours type, team and league; it never reads start, count,
level, sort or year. Because start is ignored, every page returns the
same full set, so the client concludes the page was full and asks for the
next one. One scroll produced 22 requests and 6.2 MB, stopping at
start=200 only because the client gave up -- against a filtered set of 32
items that should have been three pages.

That also explains why the bug reads as erratic rather than broken:
league=13&position=ST returns every Premier League player instead of
Premier League strikers. Plausible, wrongly sized, hard to notice.

Measured filtered sets, from the real cluttered club -- these are the
acceptance test for the fix:

  unfiltered            1962
  league=13              350
  league=13&team=5        32

Two client behaviours worth carrying forward: the picker fires a query
per highlighted entry, not per selection (two requests for one club
pick), and parameter ORDER is not stable, so parsing must be key-value.

FIXTURE SIZE: bodies over 4 KB are truncated in the committed fixture,
with body_full_len and body_full_sha256 retained, because the same 1.1 MB
club response repeats ~25 times and its hash already proves identity.
13.3 MB -> 247 KB. The raw .ofcap keeps every byte, privately and
gitignored. Truncation is recorded per transaction so a trimmed fixture
is never mistaken for a whole response.

Audited across all three identifier surfaces -- headers, JSON bodies,
query strings -- before and after the size change: no leaks. 6/6
sanitiser mutations still killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:32:57 +00:00
funman300 0b66662525 utas: first real corpus, and two sanitiser gaps the audit caught
24 transactions across 11 connections from a retail session: login,
hub, one pack open, two squad saves, a quick-sell, with before/after
state manifests. Raw .ofcap stays gitignored at 0600; the sanitized
corpus is committed as adapter fixtures.

TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER.

1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens
   were being redacted correctly and these were not. A committed fixture
   is a published fixture.

2. Then, with those fixed, the audit fired AGAIN on the file about to be
   committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in
   the QUERY STRING. Three input surfaces carry identifiers -- headers,
   JSON bodies, and query strings -- and the sanitiser knew about two.

Both fixed in the tool rather than by editing the file, with a
regression test and a mutation for the query path.

AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported
the query-redaction mutation as SURVIVED while a hand-run of the same
mutation killed it. Cause: the harness pointed at a stale scratchpad copy
of the test that pre-dated the query assertion, so it was faithfully
testing the mutated tool against a test that could not detect the
mutation. That is the same class as the build guard checking the wrong
binary and cargo reusing a binary compiled from mutated source -- the
third instance today of measuring the wrong artifact. The harness now
resolves ROOT from its own location and runs the COMMITTED test; the
stale copy is deleted.

Harness committed as scripts/mutate-utas-observe.py so this is repeatable
rather than a thing that happened once in a scratch directory. 6/6 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:59 +00:00
funman300 cdea85e214 utas: standalone recording proxy that tees rather than rebuilds
UTAS needs a real request/response corpus before any Rust is written: it
is where protocol shape and FUT state start being coupled, so guessing is
worse here than it was for Blaze. The oracle truncates logged bodies at
~200 chars, and raising that cap would mean editing the behavioural
specification to make it easier to copy -- backwards. A proxy gets the
same evidence and leaves the oracle untouched.

THE DESIGN RULE: TEE, DO NOT REBUILD.

UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive,
pipelining and chunked transfer are all live. A proxy that parses a
request and re-emits it can corrupt the traffic it exists to observe --
and that corruption would present as a UTAS bug, pointing the
investigation in exactly the wrong direction. So bytes are copied
verbatim in both directions and a second copy goes to disk; transactions
are reconstructed later, offline, from that copy. A parser bug therefore
spoils the record and never the session.

Standalone, NOT in the container, so the same tool can later sit in front
of a Rust UTAS host and replay an identical captured request against both.

Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode
0600 and gitignored; sanitized/transactions.jsonl is the committed
artefact. Bodies are preserved EXACTLY and sanitised second -- only
known-secret headers and JSON keys are replaced, structure is never
reshaped, and every redaction is recorded in the transaction so a reader
knows what was touched.

Captured per transaction: connection id, sequence, relative and wall
time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED
ORDER as pairs (a dict would drop duplicates and ordering), raw body and
length for both directions, status, and observed keep-alive.

Verified as two independent properties, because they fail differently:
transparency (bytes through the proxy identical to bytes direct, Date
masked, with the mask asserted to have fired) and fidelity (parsed
transactions match what was sent, including a dechunked response and a
300-byte POST body). 5/5 mutations killed, including "record but do not
forward", "drop the last byte of every chunk" and "stop redacting".

scripts/test-utas-observe.py is committed alongside it: a capture tool
nobody can re-verify is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:13:06 +00:00
funman300 05f6147433 http: extract the shared body drain; fix a flaky test race it exposed
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.

The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
answering while the client is still sending leaves unread data in the
receive queue and Linux turns the close into an RST rather than a FIN --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.

SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:

              redirector   roster
  head cap    65536        16384
  read chunk  4096         1024
  on error    abort        proceed if any bytes arrived

Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.

Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.

Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.

Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.

Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:04:08 +00:00
funman300 8f3b659c33 lifecycle: one host-lifecycle helper; roster.sh; ban pkill -f
redirector.sh and the coming roster.sh needed the same five rules, each
of which cost something to learn:

  * resolve /proc/PID/exe; never match a command line. `pkill -f` /
    `pgrep -f` match any shell whose ARGUMENTS mention the name, including
    the shell running the command. That has killed this session's own
    shell twice, and is now banned in migration tooling -- the helper
    contains no `-f` matching and the header says why.
  * `readlink`, not `readlink -f`. After a rebuild the link reads
    "<path> (deleted)" and -f resolves it to nothing, so the orphan check
    goes blind to exactly the long-lived processes it exists to find. Two
    orphans hid there, one serving the wrong certificate.
  * stop PROVES the process is gone and the port free.
  * an ambiguous binary is an error for start/verify but NOT for
    stop/status: rollback must never be blocked by a question about the
    build tree.
  * verify the RUNNING process's commit, not the artifact on disk, which
    a rebuild can silently advance past.

Copying those into a second script would have been the same mistake as
copying the TLS setup. Instead scripts/host-lifecycle.sh owns them and a
service supplies four facts: name, crate, executable, port variable.
redirector.sh goes from 178 lines to 26 and roster.sh is 24, with no
behaviour change -- the refactored redirector.sh still sees the live
armed process (pid 830736, port 42227) and still refuses correctly
because HEAD has moved past it.

Paths are unchanged (rundir, pidfile, portfile, commit stamp, log), so
the currently running redirector stays manageable across this refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:15 +00:00
funman300 c9ae914910 roster-host: transport host for the FUT roster update, lifecycle-matched
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.

Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:

    dwell after responding   0 ms      (redirector: 300 ms)
    request body             drained   POST answered only once it arrives
    close                    clean FIN, never RST
    keep-alive               none      one request per connection

The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.

Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:

    GET  230B   HEAD 163B   POST 163B
    drained=True  reset=False  answered_before_body=False
    keepalive: second request accepted by the socket, never answered

Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.

9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".

drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.

Not deployed and not switched: Python still serves :8081.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:41:11 +00:00
funman300 84e81f2037 tls: extract a shared listener; move FIFA 17's profile into its adapter
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.

Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.

Split along the line the architecture already draws:

  openfut-tls               how to build an acceptor. Game-independent.
                            Knows nothing about which suites any client
                            offers.
  adapter-fifa17::tls       what FIFA 17 was OBSERVED to offer: the six
                            enabled suites, the two refused, the TLS 1.2
                            window, the EA SNI. Plain strings, so the
                            adapter keeps its lean dependencies -- reading
                            a card table should not build OpenSSL.
  redirector-host           joins the two. Chooses no cipher of its own.

Behaviour is unchanged, and shown to be:

* tests/fifa17_tls_profile.rs carries over every case from the deleted
  module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
  suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
  a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
  when gates 1-14 passed. A "pure refactor" that cannot fail is not a
  claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
  the retail client negotiated at 17:09 today.

Two improvements fall out of having one place to look:

* the startup banner now prints cert_sha256. The mismatch above raised no
  error at startup and broke the client much later with nothing logged;
  it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
  is gate evidence and gets read by people.

Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:30:01 +00:00
funman300 696386a9c1 client_arm: verify the hosts entry by resolution, not by presence
The old check was `grep easw /etc/hosts && echo ok`. It passed on ANY
matching line -- including a line that shadows ours. glibc returns the
first match, and the sed above only deletes lines this script wrote
(`# openfut`), so a foreign entry earlier in the file wins forever and
re-running the script never helps.

Observed today: a leftover `127.0.0.1 easw.easports.com` from the
single-machine era, before the backend moved to its own host. Every arm
reported "/etc/hosts ok" while the name resolved to loopback.

Now it resolves the name -- the same call the game makes -- and compares
address to address, so a server given as a hostname is handled too. On a
mismatch it prints the offending lines with line numbers and says how to
fix them.

It does NOT delete them. This script writes one tagged line and owns only
that line; silently removing entries a user put there by hand is a bigger
hazard than the shadowing it would cure.

Reported as a warning, not an error, because it is survivable: the
responders advertise the server address, so the game stops using this
hostname after the first redirected contact. FIFA reached the FUT hub
today with this exact misconfiguration in place. Claiming it is fatal
would be wrong, and a check that overstates its findings gets ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:19:52 +00:00
funman300 aa2679162d redirector.sh: never leave it ambiguous which binary is under test
Two defects, both found by the guards misfiring rather than by reading:

1. BIN preferred target/debug and fell back to release only when debug was
   absent. `cargo build --release` therefore produced a correct binary while
   the script kept inspecting a stale debug one, and the build guard refused
   with a message naming a commit nobody was trying to run. The guard was
   right that something was stale — it just pointed at the wrong artifact.
   Disagreement between the two is now an explicit refusal naming both, with
   OPENFUT_REDIRECTOR_BIN as the deliberate override.

   The refusal is recorded at load and raised only by `verify` and `start`.
   `stop` and `status` must work in any build-tree state: rollback can never
   be blocked by a question about which artifact would have been started.

2. `verify-running` read the stamp file without checking the process still
   existed. The stamp outlives the process, so after a stop it reported on a
   corpse — either "identity OK" or a REFUSAL naming a commit, both implying
   something was running when nothing was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:20:16 +00:00
funman300 096d1c882f switch: fix the unquoted python string that broke status with no --name
`cmd_status` has two paths. The `--name` path filters on an exact tag and
works. The no-name path — the "show me every switch on this box" survey,
which is how an orphan switch under a different name would be found —
built its python with shell quote-juggling and never closed the string
literal, so it died with a SyntaxError every time.

It failed loudly (rc=1, a traceback) rather than reporting "no rules", so
it never lied about the state. But it also meant the survey path had
never once run, which is the more useful lesson: every branch of a safety
tool needs exercising, not just the branch the happy path takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:16:10 +00:00