The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
Two operator-value features: evidence that shows HOW it happened, and the
one-line answer to "what happened overnight".
Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up):
- The ring buffer now stores per-frame head boxes, and an event captures the
frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's
evidentiary value is in the seconds BEFORE the person hit the floor - one
end-state frame never showed how it happened.
- Each past frame is blurred with the head positions of ITS OWN moment -
blurring history with the current tick's boxes would leave a face exactly
where it actually was (PIPA).
- Right after startup the ring is short: fewer frames go out, never zero and
never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by
position (offsets are edge config the server doesn't know).
24h catch-up brief:
- The events console renders one line above the stream on load - "지난 24시간
- 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing
GET /api/report?days=1, with a link into /report. High-tinted when the
window held critical/high events; hidden entirely until data arrives.
Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup,
3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render
(brief line with severe tint on the console; detail page showing the
3-frame timeline with captions; no console errors).
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.
- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
worst-camera ranking, response time (TTA - acknowledged critical/high only,
FP-tagged rows excluded: dismissing a false alarm is not an emergency
response; clock-skew guarded), the near-miss pyramid (near_fall vs
fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
posture-load exposure, occupancy-rule violation counts. ergo_report rows
feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
each with its one-line reading), bar lists for types and cameras (critical
presence highlighted), ergo bands as share-of-observed-time, rule tiles.
Print CSS strips the app chrome - the 인쇄 button produces the meeting
handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
(near_fall, ergo_report) can now be folded out of the live stream, or a
single type isolated for investigation.
Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
- cafe24 package now stages server/migrations/ beside schema.sql: schema.sql is
for a FRESH database only, an already-imported host upgrades by running the
migrations in date order (then deletes them, like schema.sql).
- Rebuilt dist/1_cafe24_upload (+ move-detect-cafe24.zip): autoloader resolve
check 28 classes, parse check, all of today''s controllers/assets/schema in.
- Rebuilt dist/2_pi_install with --cross: aarch64/cp311 wheelhouse (11 wheels,
onnxruntime included), HEF verified against the pinned hailo8 digest,
requirements.lock + sbom.json + SHA256SUMS regenerated, ergonomics detector
staged.
- FLAG: the MoveNet ONNX upstream URL 404s, so this Pi bundle is Hailo-only
(no CPU-fallback pose model). fetch_movenet.py needs a new source URL before
a delivery that requires the CPU path.
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).
- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
(can't be fat-fingered as the default); resolved rows show a mono verdict
("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
refreshed on a slow 60s cadence plus immediately after a tag. Detail view
shows the tag.
Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.
Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
before the FALLEN confirm. Treated as a full event per product decision -
same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
carries the foot point in the zones' normalized 0..1 space (reusing the zone
detector's own coordinate math); ghost's pixel-space loss point is normalized
at emit. to_payload omits the key when absent, so an old server ignores it.
Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
[x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
spatial points with the same fail-closed site scoping as the event list.
UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.
Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):
- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
A solo worker in a buddy-rule zone is a compliance violation and a rescue-
delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
A short detection dropout (<=5s grace) does NOT reset the dwell timer -
without that, run_once's stale-track filter would quietly neuter every
10-minute rule at real-world detection rates.
Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).
Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.
UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).
Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
- P2-6 tracking: global greedy IoU matching (all pairs, best overlap first)
replaces track-iteration-order greedy, so a crossing worker's box can no
longer be stolen by whichever track came first. Any track in a contended
match (person overlapping 2 tracks, or track overlapping 2 persons) is
reported via tracker.ambiguous(); run_once resets its detector + smoother
state so FALLEN/baseline/immobility never ride an id swap onto the wrong
person. A genuinely fallen worker re-confirms within prone_confirm_s.
- P2-7 fall: STANDING -> FALLING now requires sustained prone
(falling_confirm_s, 1.0s). One prone-looking frame (a bend, a bbox glitch)
no longer arms the ghost detector's CRITICAL if the person then leaves.
FALLEN / no_motion timing is unchanged (keyed off prone_since).
- P2-8 app: run_once feeds the engine and smoothers only tracks re-detected
this tick; a track coasting inside max_age with a frozen frame is no-data,
so prone-confirm / no-motion / immobility timers cannot accrue on stale
data. Re-detection still confirms the fall (dropout pauses evidence).
TDD: 7 new tests verified failing on the pre-fix code (git stash), then
203 passed / 2 skipped (hardware) with the fixes.
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.
- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match
Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
Two approved data-model changes.
P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).
P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.
Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.
Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Records the pass that closed P4-5/6/7/8, P5-3/4, P2-9/10/11 and documents
why the remaining 8 are not solo-doable (data-model decisions: P1-9,
P3-2b; on-hardware safety-timing validation: P2-6/7/8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):
- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
point into or out of a zone. Now averages only ankles with conf >= 0.2
and falls back to the bbox bottom-centre when neither is trustworthy.
- P2-11 geometry.py: head_below_hips used the NOSE regardless of
confidence. Now returns False when the nose is below the visibility
threshold, so an unconfident/hallucinated nose can't fire the
head-below-hips fall cue. The confident-nose path is unchanged
(test_fall.py stays green).
- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
a multi-worker site every worker but one is silently invisible to
fall/zone/ghost detection. Construction now logs a loud startup WARNING
pointing to the Hailo backend.
Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The offline bundle shipped unpinned pip deps with no digests, no SBOM,
and no signature — an air-gap target can't re-resolve from PyPI, so a
truncated or tampered wheel would install silently.
build_offline_bundle.sh now emits, from the resolved wheelhouse:
- SHA256SUMS — sha256 of every wheel + model
- requirements.lock — exact name==version pins for a reproducible rebuild
- sbom.json — CycloneDX 1.5 components (name, version, pypi purl, sha256)
- SHA256SUMS.asc — optional detached GPG signature when SIGN_KEY is set
MANIFEST advertises the integrity set and whether it is signed.
install_offline.sh verifies `sha256sum -c SHA256SUMS` (and the signature,
if present) BEFORE any pip install, hard-failing on mismatch; older
bundles without the file pass with a warning.
The generation logic was verified end-to-end on a synthetic stage:
requirements.lock/SHA256SUMS correct, `sha256sum -c` passes, sbom.json is
valid CycloneDX JSON. Both scripts pass `bash -n`.
Tests (test_deploy_artifacts.py): pinned integrity manifest is emitted
(+ optional SIGN_KEY), installer verifies integrity before install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The air-gapped bundle never fetched the Hailo HEF, so an offline Hailo
install had no pose model. Two latent breaks also made the offline path
unusable regardless of P5-3:
- build [2/5] ran `fetch_movenet` unconditionally; with the MoveNet
source unreachable that aborted the whole build under `set -e`.
- install_offline copied only `*.onnx`, so even a HEF placed in the
bundle never reached $PREFIX/models for the backend gate to find.
Fixes:
- build_offline_bundle.sh fetches the $HAILO_ARCH (default hailo8) HEF
via edge.tools.fetch_hailo_hef (pinned sha256, aborts on mismatch) and
stages yolov8s_pose.hef. MoveNet ONNX is now optional (guarded fetch →
Hailo-only bundle if unavailable). onnxruntime moves to a best-effort
optional wheel. MANIFEST lists models + available pose_backends.
- install_offline.sh copies *.hef (and tolerates a missing *.onnx).
Tests (test_deploy_artifacts.py): bundle fetches+stages the HEF, MoveNet
is optional (guarded), installer copies the HEF. Both scripts pass
`bash -n`. Full python suite: 152 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The ingest snapshot loop silently `continue`d on re-encode and file-write
failures (evidence lost with a 201), and a DB-index throw after the file
landed left an orphan JPEG on disk with no row — or bubbled into a 500
that discarded an already-stored event.
Extracts storeSnapshot(): re-encode → write file → index, in that order
so bytes exist before a row points at them. Every failure is error_log'd
instead of swallowed; a DB-index failure after the write @unlinks the
orphan file. A snapshot problem no longer fails the ingest — the event is
already persisted, so the 201 stands.
Test: testSnapshotIndexFailureCleansOrphanFileButKeepsEvent uses a
throwing snapshot repo and asserts 201, the event is stored, and no *.jpg
is left in the snapshot dir.
phpunit: 90 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The event list was hard-capped at the newest 100 with no way to reach
older events. Adds keyset pagination:
- EventRepository::page(limit, severity, beforeTs, beforeId) on both the
PDO and in-memory implementations, ordered (occurred_at DESC,
event_id DESC). The cursor is the last row's (occurred_at, event_id),
so pages stay stable as new events arrive at the head — no overlap, no
skips. occurred_at is bound as a string in SQL to keep DECIMAL
precision.
- EventListController reads `limit` (default 50, capped 200) and `cursor`
("occurred_at:event_id", malformed input ignored) and returns
`next_cursor` only on a full page. recent() is retained for existing
callers; the response gains next_cursor additively.
The live-tail dashboard keeps consuming page 1; a history-browse UI can
build on the same cursor. Test: testListPaginatesWithCursor.
phpunit: 89 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
P4-8: AuthController::login now verifies the CSRF token the login form
already emits (Csrf::check over session vs posted field), returning 403
before the user lookup. Closes a login-CSRF / session-fixation hole where
a forged cross-site POST could silently log a victim into an attacker's
account. Tests seed the session+body token; adds
testLoginRejectsMissingOrWrongCsrf.
P4-6: ZoneConfigController::save wraps the repository write in
try/catch(\RuntimeException) so an ownership/device mismatch is a 400
(invalid_zone) client error, not an uncaught 500. Adds
testSaveOwnershipMismatchIs400NotError with a throwing repo double.
phpunit: 88 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning).
- P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL
'Data too long' 500 the edge retries forever.
- P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'.
- P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package.
python + phpunit (86) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P5-2 [H] build_offline_bundle.sh native branch hard-fails when uname -m != TARGET_ARCH
(an x86 build was producing an aarch64-named tarball of x86_64 wheels).
- P5-5 [H] systemd unit adds DeviceAllow char-media + /dev/dma_heap for the CSI camera —
picamera2/libcamera on Pi 5 needs them or the unit denies its own camera. OFF-HW: verify on Pi.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P3-1 [H] _head_boxes sizes the anonymization region from the person bbox (scales with
distance) + bbox-top fallback when the nose keypoint is low-confidence; a fixed ~40px
patch had left close faces identifiable. test_faceblur.py.
- P3-3 [H] fetch_movenet.py rewritten to stage-validate (reject HTML/LFS-pointer, optional
pinned sha256+size, fail loud) per the SHA256 spec requirement. Discovery: the old PINTO
raw URL is DEAD (GitHub 404 → it was silently saving an HTML page as the .onnx). Non-blocking
for the Hailo delivery path; the CPU MoveNet fallback needs a valid MOVENET_ONNX_URL pinned.
python + phpunit suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).
## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.
## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
(docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
scoped to the mount.
## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW