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-3ba → bind_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-e60 → bind_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:
- "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.
- 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.
Summary
audit.tool_usageholds 3,292 rows withagent_id IS NULLanderror_type='identity_required', spanning 2026-06-12 12:57 → 2026-07-29 16:05and still accruing during analysis. Roughly 2,206
process_agent_updateobservations 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/callstrict-identity gate, the only site in the repo that writes
error_type='identity_required'. MCP-dispatch and Path-C refusals wrap insuccess_response()and therefore audit assuccess=true, so this drop isentirely on the REST tool surface.
latency_msis0on 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
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_resultrecover 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 classpre-attribution, not a separate bug —
session_idis justarguments.get("client_session_id")(http_tool_service.py:224), and csidpresence 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_mismatchvs 29PATH2_RESUME_MISS).Path:
http_api.py:337consult_sticky_binding(redis_recovery=False)isstructurally skipped for any csid-bearing request (
identity_step.py:346-352,cacheablefalse whenclient_session_idpresent) →derive_session_keyreturns the csid verbatim (
session.py:702-713;agent-keys skip modelscoping) →
resolve_session_identityPATH 1 hits Redissession:<csid>→resolution.py:448-500compares the binding's storedbind_ip_uaagainst thecaller's current fingerprint; mismatch under
strictreturns True →PATH3_MINT_REFUSED→_resolve_http_bound_agentreturns None → the gate athttp_tool_service.py:170-202refuses.Live env confirmed:
STRICT_IDENTITY_REQUIRED=trueandUNITARES_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:
9f1c93curl/8.7.1—~/.claude/hooks/stop-checkin.sh355f8ePython-urllib/3.14— pluginscripts/checkin.py0af84a/eca485claude-code/2.1.220/2.1.218(CLI) — the MCP pathbind_ip_uais stamped from whichever client bound first(
persistence.py:153-170, :349-350, :379-380). So the deciding variable is whichclient 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_uato the claude-code CLI fingerprint. Both REST Stop hooks thenmismatch and are refused for the life of the session.
Reproduced on a live session during this investigation:
session:agent-b7372b63-3ba→bind_ip_ua = 127.0.0.1:0af84a;audit.tool_usagefor that key shows 0 successfulprocess_agent_update, 2refusals. Its turn observations were dropped because it bound properly.
Contrast
session:agent-94c0dd1c-e60→bind_ip_ua = 127.0.0.1:355f8e(urllibbound 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_usage1:1):~/.claude/hooks/stop-checkin.sh:146-168(curl)scripts/checkin.py:155-171(urllib)PATH2_RESUME_MISS, no fingerprintstop-checkin.shis the dominant refusal source and contributes ~0.4observations/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 the0.4.9checkout.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-a30exists in Redis withbind_ip_ua=100.84.100.128:000000(empty-UA client) while a second Pi clientpresents
100.84.100.128:9df06c(Python/3.13 aiohttp/3.13.3) — 151PATH1_FINGERPRINT_MISMATCHevents. 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:
core.sessionsrow healed Lumen's Jul 19–23outage" (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) underan explicit "when
resume=False, don't return PG identity" rule (:1069). APG 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 failingkeys (6 present, 12 absent, zero splits). The Jul 9 burst settles it
independently: it recovered at 14:09:52 with no
core.sessionsrow inexistence.
Lumen outage. A durable binding carrying the same stale
bind_ip_uareproduces 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 0rows, and its reader
get_session_binding(src/db/mixins/session.py:226) haszero 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-168posts withcurl -sf --max-time 3 ... >/dev/null 2>&1 &, so-sfplus discarded outputplus 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.shas a per-turn emitter. 111 refusals for 1landed observation, and it duplicates the plugin on every turn where both fire.
Tier 3 — persist a per-slot
continuity_token. Verified server-side atsession.py:324-330, resolved via PATH 2.8. Correct because it isfingerprint-independent and the slot file is per-session, therefore
per-agent.
Do not fix it with the operator token
Adding
-H X-Unitares-Operatorto the check-in hooks (mirroringoutcome-tracker.sh:110-113) would fabricate attribution and collapse everyagent on the machine onto one identity:
agent_uuidin the body,http_api.py:299-317runsresolve_operator_identityand returns beforederive_session_key(:337) —the operator identity wins over the caller's
client_session_id. Theheader becomes the attribution rather than authorizing it.
operator.py:142-147keys onsha256(token)[:16],:212-231memoizes oneagent_uuidper fingerprint for 300s,:233-250mintswith
force_new=Trueon miss.operator.py:39-40states the rule directly:"Use one token per client identity, not a shared one."
http_tool_service.py:180-183returns None themoment
get_context_agent_id()is truthy, with noproof_origincheck.Refusals become wrongly-attributed successes that write state.
core.agent_state.syntheticdefaultsfalse NOT NULL(
src/db/mixins/state.py:36-49), so blended rows satisfysynthetic IS NOT TRUEatscripts/analysis/eisv_individuality_v2.py:132andenter the pre-registered population permanently. 20 distinct agents /
4,456 successful check-ins in 7 days would fold onto one UUID, crossing
MIN_STATES=100within hours as an "agent" whose trajectory is a mixture of20.
The precedent argues against it:
outcome-tracker.sh:69-73hard-skips unlessthe full UUID is present and sends it in the body (
:95-107), hittinghttp_api.py:288beforeresolve_operator_identityis reached. There the tokenauthorizes explicit attribution; in the check-in hook there would be nothing to
authorize.
Also do not pass the slot cache's
agent_idfield as attribution — it is alabel (
Claude_20260729); the UUID lives underuuid. The gate's check is baretruthiness (
http_tool_service.py:185) buthttp_api.py:288requires len==36with 4 hyphens, so a label would bypass the gate and then resolve to nothing —
strictly worse than the current drop.
Related
PATH3_MINT_REFUSEDpath)Separate latent gap
stop-checkin.sh:65-67exits 0 on an empty cache, and the plugin's lazyonboard_helper.py:229writes only$PWD/.unitareswhilestop-checkin.sh:40reads only
$HOME/.unitares— that yields no row at all, not a NULL row, soit is invisible in this dataset.
If a
$HOME/$PWDmirror is added, it must be gated on a slot being present —mirroring
session_cache.py:261if args.kind == "session" and slot:. Withoutand slot,onboard_helper'sslot=Nonepath (_slot_filename→session.json, reachable becausehooks/post-stop:113passesRAW_SLOTwhich_session_lookup.py:190can emit empty) writes$HOME/.unitares/session.json—the shared cache removed 2026-04-18.