Skip to content

REST strict-identity gate drops ~2,200 check-in observations: fingerprint-mismatch refusals audit as agent_id=NULL/identity_required (3,292 rows since Jun 12) #1401

Description

@cirwel

Summary

audit.tool_usage holds 3,292 rows with agent_id IS NULL and
error_type='identity_required', spanning 2026-06-12 12:57 → 2026-07-29 16:05
and still accruing during analysis. Roughly 2,206 process_agent_update
observations are plausibly lost
, 1,140 of them unrecoverable at the
(session, tool) level — a 10.9% drop rate fleet-wide for that tool, 28.0%
restricted to the 161 affected session keys. Eleven of the top 20 affected agents
lost 100% of their check-ins.

Every one of the 3,292 rows comes from a single code path:
src/services/http_tool_service.py:245 — the REST /v1/tools/call
strict-identity gate, the only site in the repo that writes
error_type='identity_required'. MCP-dispatch and Path-C refusals wrap in
success_response() and therefore audit as success=true, so this drop is
entirely on the REST tool surface. latency_ms is 0 on all 3,292 rows
(successes average 144–337ms) — the refusal precedes any DB work, consistent with
the pre-dispatch gate at :229-247.

Two distinct populations — do not conflate them

Class Count Data lost?
Fail → success 188–433ms later, same key 510 (23%) No
Contiguous outage, never recovers ~1,140 Yes

The fast class is two Stop hooks double-posting, not a retry after a late
cache write — of 1,345 refusals in 30 days, exactly one precedes its key's
first success, so it is not a startup race. Recovery is confined entirely to
process_agent_update; outcome_event, knowledge, observe, sync_state,
and record_result recover 0%.

Failures cluster: 88.3% sit inside 53 windows of ≥10 failures (30-min gap
boundary); only 39 are isolated singletons. Largest window is 15h12m / 591
failures
.

The 1,097 rows with session_id IS NULL (Jun 12–28) are the same class
pre-attribution
, not a separate bug — session_id is just
arguments.get("client_session_id") (http_tool_service.py:224), and csid
presence fleet-wide went 3.5–5.4% → 96.7–98.8% across 2026-06-28. They carry the
identical sub-second retry signature (235 of 801 June failures recover <500ms vs
~0.3% chance expectation, ~100× above chance).

Mechanism

Dominant cause is the strict-mode fingerprint hijack guard, outnumbering the
no-binding case ~10:1 in the live log (286 reason=fingerprint_mismatch vs 29
PATH2_RESUME_MISS).

Path: http_api.py:337 consult_sticky_binding(redis_recovery=False) is
structurally skipped for any csid-bearing request (identity_step.py:346-352,
cacheable false when client_session_id present) → derive_session_key
returns the csid verbatim (session.py:702-713; agent- keys skip model
scoping) → resolve_session_identity PATH 1 hits Redis session:<csid>
resolution.py:448-500 compares the binding's stored bind_ip_ua against the
caller's current fingerprint; mismatch under strict returns True →
PATH3_MINT_REFUSED_resolve_http_bound_agent returns None → the gate at
http_tool_service.py:170-202 refuses.

Live env confirmed: STRICT_IDENTITY_REQUIRED=true and
UNITARES_SESSION_FINGERPRINT_CHECK=strict.

The fingerprint is f"{host}:{md5(UA)[:6]}" (src/http_api.py:56-63).
Reproduced against the server's own preimage log:

Fingerprint Client
9f1c93 curl/8.7.1~/.claude/hooks/stop-checkin.sh
355f8e Python-urllib/3.14 — plugin scripts/checkin.py
0af84a / eca485 claude-code/2.1.220 / 2.1.218 (CLI) — the MCP path

bind_ip_ua is stamped from whichever client bound first
(persistence.py:153-170, :349-350, :379-380). So the deciding variable is which
client performed the original bind, and the refusal follows mechanically.

The sub-class that actually loses data gets worse the more correctly an agent binds

An agent that onboards over MCP — the canonical identity source — binds
bind_ip_ua to the claude-code CLI fingerprint. Both REST Stop hooks then
mismatch and are refused for the life of the session.

Reproduced on a live session during this investigation:
session:agent-b7372b63-3babind_ip_ua = 127.0.0.1:0af84a;
audit.tool_usage for that key shows 0 successful process_agent_update, 2
refusals
. Its turn observations were dropped because it bound properly.

Contrast session:agent-94c0dd1c-e60bind_ip_ua = 127.0.0.1:355f8e (urllib
bound first): the plugin's check-in succeeds, curl's is refused, and a paired
success lands 0.2–0.6s later. The observation survives; the refusal row is a
duplicate.

Caller split — not co-equal

Measured over the live log window (2026-07-26 19:36 → 2026-07-29 16:19; 152 REST
refusals, matching audit.tool_usage 1:1):

Caller Refusals Successful check-ins
~/.claude/hooks/stop-checkin.sh:146-168 (curl) 111 (~73%) 1
plugin scripts/checkin.py:155-171 (urllib) 23 150
PATH2_RESUME_MISS, no fingerprint 18

stop-checkin.sh is the dominant refusal source and contributes ~0.4
observations/day — retiring it removes ~73% of REST refusals and is close to
density-neutral. The plugin is the working emitter, not a co-equal twin. Note the
plugin runs from
~/.claude/plugins/cache/unitares-governance/unitares-governance/0.4.7/, not the
0.4.9 checkout.

Lumen (agent-69a1a4f7-a30, 378 refusals, 11.5% — the largest single key)

This is the same fingerprint-mismatch mechanism at a second IP, not a
missing-binding case: session:agent-69a1a4f7-a30 exists in Redis with
bind_ip_ua=100.84.100.128:000000 (empty-UA client) while a second Pi client
presents 100.84.100.128:9df06c (Python/3.13 aiohttp/3.13.3) — 151
PATH1_FINGERPRINT_MISMATCH events. Two Pi clients contending for one key.

Lumen's 378 failures are two solid blocks with no interleaved successes; each
block ends exactly one 3-minute cadence tick after the last failure. During the
Jul 9 outage no twin key absorbed its check-ins (busiest other session in that
10h45m window had 4 successes) — the 264 check-ins are genuinely lost, not
re-attributed.

Corrections to two plausible-but-wrong readings

Recorded so they are not re-derived:

  1. "Provisioning the durable core.sessions row healed Lumen's Jul 19–23
    outage"
    (it ended 46s after that row appeared) — co-timing, not
    causality.
    A strict mismatch sets resume = False (resolution.py:989),
    and both PATH 2 return branches are gated on resume (:1098, :1125) under
    an explicit "when resume=False, don't return PG identity" rule (:1069). A
    PG session row cannot clear a fingerprint mismatch. What ended it was a
    Redis rebind, which co-writes both stores — evidenced by 1:1
    Redis-present/core.sessions-present correspondence across all 18 failing
    keys (6 present, 12 absent, zero splits). The Jul 9 burst settles it
    independently: it recovered at 14:09:52 with no core.sessions row in
    existence.
  2. Therefore a durable-binding / PATH-2 fallback would NOT have prevented the
    Lumen outage.
    A durable binding carrying the same stale bind_ip_ua
    reproduces the refusal. The Lumen class needs rebind/fingerprint stability, or
    a fingerprint-independent credential — not durability.

For reference: core.session_bindings (migration 51's dual-write target) has 0
rows
, and its reader get_session_binding (src/db/mixins/session.py:226) has
zero callers. Real, but not the cause here.

Proposed direction

Tier 1 — surface the failure (safe now). The refusal is currently invisible
at the caller: stop-checkin.sh:146-168 posts with
curl -sf --max-time 3 ... >/dev/null 2>&1 &, so -sf plus discarded output
plus backgrounding means a refused check-in is indistinguishable from a delivered
one. Log the typed refusal locally. This is the only change with no identity-model
risk.

Tier 2 — retire stop-checkin.sh as a per-turn emitter. 111 refusals for 1
landed observation, and it duplicates the plugin on every turn where both fire.

Tier 3 — persist a per-slot continuity_token. Verified server-side at
session.py:324-330, resolved via PATH 2.8. Correct because it is
fingerprint-independent and the slot file is per-session, therefore
per-agent.

Do not fix it with the operator token

Adding -H X-Unitares-Operator to the check-in hooks (mirroring
outcome-tracker.sh:110-113) would fabricate attribution and collapse every
agent on the machine onto one identity
:

  • With no agent_uuid in the body, http_api.py:299-317 runs
    resolve_operator_identity and returns before derive_session_key (:337) —
    the operator identity wins over the caller's client_session_id. The
    header becomes the attribution rather than authorizing it.
  • One token → one identity: operator.py:142-147 keys on sha256(token)[:16],
    :212-231 memoizes one agent_uuid per fingerprint for 300s, :233-250 mints
    with force_new=True on miss. operator.py:39-40 states the rule directly:
    "Use one token per client identity, not a shared one."
  • The gate cannot catch it: http_tool_service.py:180-183 returns None the
    moment get_context_agent_id() is truthy, with no proof_origin check.
    Refusals become wrongly-attributed successes that write state.
  • core.agent_state.synthetic defaults false NOT NULL
    (src/db/mixins/state.py:36-49), so blended rows satisfy
    synthetic IS NOT TRUE at scripts/analysis/eisv_individuality_v2.py:132 and
    enter the pre-registered population permanently. 20 distinct agents /
    4,456 successful check-ins in 7 days would fold onto one UUID, crossing
    MIN_STATES=100 within hours as an "agent" whose trajectory is a mixture of
    20.

The precedent argues against it: outcome-tracker.sh:69-73 hard-skips unless
the full UUID is present and sends it in the body (:95-107), hitting
http_api.py:288 before resolve_operator_identity is reached. There the token
authorizes explicit attribution; in the check-in hook there would be nothing to
authorize.

Also do not pass the slot cache's agent_id field as attribution — it is a
label (Claude_20260729); the UUID lives under uuid. The gate's check is bare
truthiness (http_tool_service.py:185) but http_api.py:288 requires len==36
with 4 hyphens, so a label would bypass the gate and then resolve to nothing —
strictly worse than the current drop.

Related

Separate latent gap

stop-checkin.sh:65-67 exits 0 on an empty cache, and the plugin's lazy
onboard_helper.py:229 writes only $PWD/.unitares while stop-checkin.sh:40
reads only $HOME/.unitares — that yields no row at all, not a NULL row, so
it is invisible in this dataset.

If a $HOME/$PWD mirror is added, it must be gated on a slot being present —
mirroring session_cache.py:261 if args.kind == "session" and slot:. Without
and slot, onboard_helper's slot=None path (_slot_filename
session.json, reachable because hooks/post-stop:113 passes RAW_SLOT which
_session_lookup.py:190 can emit empty) writes $HOME/.unitares/session.json
the shared cache removed 2026-04-18.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions