Overrides res.users._login. When the login string does not resolve to
any user, super() raises AccessDenied; we record a row with user_id=NULL
and failure_reason="unknown_user", then re-raise. Closes the gap where
typo'd or scanned logins would otherwise vanish from the audit trail.
The existing _fc_record_login_event helper writes through an independent
registry.cursor(), so the audit row survives the rollback that follows
the re-raised AccessDenied.
Note: in Odoo 19 _login is a plain instance method (not the classmethod
it was in earlier versions) and takes (credential, user_agent_env). The
original plan was written for the classmethod signature; corrected here
and recorded in CLAUDE.md rule #10 so future-Claude does not waste time
re-discovering it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps res.users._check_credentials. On AccessDenied, records a row with
result=failure and failure_reason='bad_password' (or '2fa_failed' when
credential['type'] == 'totp'), then re-raises. Regression test asserts
the attempted password value never lands in any audit field.
The audit row is written through registry.cursor() (independent cursor) so
it survives the rollback that follows AccessDenied — in production
odoo/service/model.py::retrying resets the transaction and http.py closes
the cursor without committing, in tests assertRaises opens its own
savepoint. Either way an inline write would vanish. Tests
enter registry_test_mode and use manual try/except to keep the audit row
visible across the savepoint hierarchy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides res.users._update_last_login to create a fusion.login.audit
row with result=success after the parent runs. The write goes through
sudo() + mail_create_nolog=True. Any exception in the audit path is
caught and logged but never propagates — a broken audit table must
never block a real user from logging in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single helper builds vals for fusion.login.audit rows from the live
HTTP request, or falls back to ip=''internal'' + geo_lookup_state=''internal''
when there is no request. Parses UA into browser/os/device_type via the
bundled user_agents library. Never reads credential[''password'']. Tests
cover: no-request fallback, UA parsing on a Chrome/Windows UA, and the
regression that no password value leaks into the vals dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Record rule grants admins an unrestricted domain on the audit log;
ACL forbids write/create/unlink for every group (audit is append-only;
sudo() inside auth hooks is the only write path). Defence-in-depth
layering: ACL is the actual gate, the rule documents and locks down
admin access path.
Tests (5, all green) cover:
test_admin_can_read_through_acl_and_rule — positive path through both.
test_acl_blocks_read_for_regular_user — base.group_user denied by ACL.
test_acl_blocks_read_for_portal_user — base.group_portal share user
denied (sensitive data leakage
surface closed at ACL layer).
test_acl_blocks_write_for_admin — append-only at the write boundary.
test_acl_blocks_unlink_for_admin — append-only at the unlink boundary.
Drop the redundant `from . import tests` from the root __init__.py —
Odoo's test loader imports `odoo.addons.<mod>.tests` directly; the
extra import was dead weight (and inconsistent with the repo pattern).
CLAUDE.md gotchas added during this task:
#6 res.users.groups_id -> group_ids rename (test setUp pitfall).
#6 ir.rule `groups` is additive, not restrictive — group-scoped
rules only apply to users in that group, they do not restrict
non-members. Default to letting the ACL gate; use rules for
row-level filters ACLs cannot express.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 16 columns per spec (user, attempted_login, result, failure_reason,
event_time, ip/geo fields, user_agent triple, device_type, database).
- Check constraint binds failure_reason presence to result value.
- Three composite indexes (user+time, login+time, geo_state+time) supporting
the per-user, failure-burst, and geo cron queries.
- Minimal admin-read ACL added so subsequent tests can verify writes.
- 3 TransactionCase tests passing: model create, failure_reason nullable on
success, geo_lookup_state='internal' accepted.
Odoo 19 deprecation note: this implementation uses the declarative
models.Constraint and models.Index attributes (Odoo 19 silently drops the
legacy `_sql_constraints = [...]` list and `init()`/raw-SQL pattern with
only a warning). Captured in CLAUDE.md rule #9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty installable module with manifest, package inits, and icon.
Subsequent tasks add the audit model, hooks, views, and tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Durable login audit for Odoo 19 (westin-v19). Captures successful and
failed authentications via _update_last_login / _check_credentials /
_login overrides, surfaces history on res.users as a smart button +
"Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com
through network_logger, 365-day retention with daily GC cron, and
emails Settings admins on N consecutive failures for the same login
within a configurable window.
Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's
res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo
is empty (warn-level stdout logging), and the container json log
rotates within days — leaving no durable login trail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(billing): session handoff — core on main, sub-project #2 (NexaCloud) next
Captures resume state for the centralized-billing initiative: core engine done
and on main, the 4-chunk decomposition of sub-project #2 (NexaCloud adapter +
dual-run reconciliation), the pending "where to start" decision, open questions,
and the test/branch workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
Add _match_api_key() class method to fusion.billing.service, with a
TDD test suite (TestServiceApiKey) covering key generation, hash storage,
positive match, and rejection of bad/inactive keys. Also fix
fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces
http_spawn() even under --no-http when --test-enable is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Local dev Odoo is Community (can't install the module). Add a guest-exec runner
that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and
runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on
Odoo 19.0 Enterprise (7 fusion_billing_* tables created).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).
Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
6 locked decisions, architecture, data model, usage engine, Lago-shaped
API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
charge, usage, webhook, reconciliation), bearer-auth API controller shell,
security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
is a sale.order(is_subscription) + sale.subscription.plan (verified live).
Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User is on Mac via Tailscale into this Windows host. Browser previews
bound to Windows localhost are unreachable from the Mac browser. Default
to text-based design discussion on this host instead of spinning up the
brainstorming visual companion. Has bitten three times now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures all clarifying-question answers + exploration findings so a
fresh Claude Code session on Mac can resume at 'propose architectural
approaches' without re-running the discovery work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs caught by entech battle test on first deploy:
1. Manifest entry landed in the 'demo' list instead of 'data' because
my anchor (fp_demo_shopfloor_data.xml) was already in 'demo' —
the entry pattern-matched into the wrong section. Demo data
doesn't load on entech (no --load demo), so the mail.template
never existed. Moved fp_tablet_pin_reset_template.xml to 'data'.
2. The fp.notification.template wrapper record referenced a model
that doesn't exist until fusion_plating_notifications loads;
fusion_plating_shopfloor doesn't depend on notifications, so
the data load ParseError'd. Removed the wrapper — the controller
calls mail_template.send_mail() directly anyway, not via the
notification dispatcher. Added an inline comment explaining why
the wrapper isn't here.
Battle test updated to drop the (now removed) wrapper xmlid check.
Battle test ALL PASS on entech after fixes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 new state-machine modes on FpTabletLock, reusing the existing
FpPinPad 4-cell component:
- request_code : 'Send temp PIN' button screen (no-PIN tile OR
after 3-fail Forgot button)
- enter_temp_code : 4-cell pad for the emailed code
- set_new_pin : 4-cell pad — choose new PIN
- confirm_new_pin : 4-cell pad — confirm new PIN
Trigger paths (per D1 + D2):
- Tap no-PIN tile -> goes straight to request_code mode
(onTileClick dispatches via tile.has_pin)
- Wrong PIN 3 times -> 'Forgot? Reset PIN via email' button appears
below the pad (gated by state.failedAttempts >= 3)
Client-side failedAttempts counter (resets on tile re-select per D14).
Server-side x_fc_tablet_pin_failed_count keeps incrementing to the
existing 5-fail lockout per D13.
After Confirm New PIN succeeds, auto-login fires unlock_session with
the new PIN. If unlock_session fails for any reason, falls back to
'PIN set, tap your tile to log in.' status.
SCSS reuses $lock-* tokens from _tablet_lock_tokens.scss — light +
dark handled by the existing token system (no new tokens needed).
Hand-Off gold gradient repeated for the primary 'Send temporary PIN'
button to match the existing tablet visual language.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail template renders the 4-digit code in both subject (mobile
notification glance) and body (big bold display). Per Rule 25 only
core res.users fields referenced; the code itself comes from ctx.
fp.notification.template wrapper enables admin UI customization of
the body without touching code. tablet_pin_reset_requested added to
TRIGGER_EVENTS selection.
Daily ir.cron purges used/expired rows > 7 days old (audit trail
lives in fp.tablet.session.event, not here, so aggressive cleanup
is safe).
Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 (triggers asset cache
invalidation on -u so the new template + SCSS load cleanly).
Phase 1 backend complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three controller changes in one commit (tight code coupling):
1. /fp/tablet/request_reset_code (Task 2) — generates 4-digit code,
emails it, returns masked_email. Specific error codes for the
frontend to switch on (no_email + manager_name, rate_limited +
wait_minutes, user_not_found, no_role, inactive). Shop-branch
role check matches existing _check_credentials per Rule 13l + 23
(all_group_ids transitive — Owners reach Technician through
implication).
2. /fp/tablet/verify_reset_code (Task 3) — verifies the emailed
code, on success mints a 5-min HMAC reset_token. Error responses
are specific (no_active_code / expired / too_many_attempts /
wrong_code with attempts_left).
3. set_pin extended to accept reset_token (Task 4) — three auth
paths now: old_pin (existing), reset_token (new), or neither
(existing — only for users with no current hash). reset_token
path is the only one that operates on a user OTHER than env.user;
token proves the legit user just verified their email.
Failure audit reuses existing failed_unlock event_type with a notes
field describing the reset-code-specific reason. Success audit uses
the new pin_reset_requested / pin_reset_code_verified /
pin_set_after_reset event_type values.
_mask_email helper added for the no-email-on-file edge case.
3 more tests cover: valid token roundtrip + set_pin, expired token
rejection, and lockout-cleared-on-reset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fp.tablet.pin.reset stores hashed 4-digit codes emailed for self-
service PIN create/reset. Per CLAUDE.md Rule 24 + Rule 13l it follows
the defensive patterns established elsewhere in the shopfloor module:
- PBKDF2-SHA256 hashing (200k iterations, matches ResUsers PIN)
- 72h TTL per D4
- 5 wrong-attempt cap per D5 (invalidates code, used_at set)
- 3 requests/60min rate limit per D6 (raises UserError)
- SQL EXCLUDE constraint enforces one-active-row-per-user per D7
- HMAC-SHA256 reset_token (300s TTL, single-use) for step 3 of
the flow (set_pin via reset_token alternative to old_pin)
Audit event_type extended with 3 new values (pin_reset_requested,
pin_reset_code_verified, pin_set_after_reset). Manager-only ACL on
the new model; sudo when endpoints need access.
10 model-level tests cover generate / replace-active / rate-limit /
verify-correct / verify-wrong / 5-attempt-cap / expired / token sign
roundtrip / tampered-sig / purpose-mismatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User goal: from the Shop Floor Terminal lock screen, a user with no
PIN (or who forgot their PIN) should be able to set / reset their
own PIN without a manager's help. Today, FpPinSetup runs only from
Preferences which requires being logged in — there's no path from
the lock screen.
Design (approved, with user-picked defaults):
- Tap tile of no-PIN user -> 'Send temporary PIN' button -> email
4-digit code, valid 72 hours -> enter code -> choose new PIN ->
auto-login.
- For existing-PIN users: 3 failed PIN entries -> 'Forgot? Reset
PIN via email' button appears below keypad -> same email flow.
- Both flows merge at: enter temp code -> set new PIN.
- Email goes to res.users.login (or partner_id.email fallback).
No-email-on-file -> 'Contact your manager: <owner>' message.
- Rate limit: 3 requests per user per rolling 60 min.
- Per-code cap: 5 wrong attempts invalidates the code.
- New model fp.tablet.pin.reset stores hashed code + expires_at
with SQL constraint enforcing one-active-row-per-user.
- 2 new endpoints (request_reset_code, verify_reset_code) + extend
existing /fp/tablet/set_pin to accept reset_token alternative
to old_pin.
- Audit: 3 new event_type values on fp.tablet.session.event.
- Reuses existing PBKDF2 helpers, FpPinPad component (mode prop),
fp.notification.template dispatch, mail.template pattern.
Per CLAUDE.md Rule 25 the mail template references ONLY core
res.users fields (object.name, object.email, object.login,
object.company_id) — ctx.code is dispatched as extra_context, not
a model field. Safe at parse-time.
Self-review fixed 2 issues:
- event_kind -> event_type (real field name on fp.tablet.session.event)
- Listed existing event_type values explicitly for context
Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan-time xmlids were wrong — entech battle test caught all 5
non-cert kanban xmlids missing. Real xmlids (queried via
ir.model.data on entech):
hold: action_fp_quality_hold (was action_fusion_plating_quality_hold)
ncr: action_fp_ncr (was action_fusion_plating_ncr)
rma: action_fp_rma (was action_fusion_plating_rma)
capa: action_fp_capa (was action_fusion_plating_capa)
check: action_fp_quality_check (was action_fusion_plating_quality_check)
cert stays unchanged — action_fp_certificate was already correct.
After fix: battle test ALL PASS — 6 sections in canonical order,
all xmlids resolve, 3 banner items pulled from real entech data
(5 draft certs, 3 of them overdue past 24h).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Version 19.0.7.0.0 → 19.0.8.0.0 (triggers asset cache invalidation
on -u so the new template + SCSS load cleanly).
Battle test script: 6-check entech smoke. Validates snapshot shape,
canonical section order, required section keys, open_kanban_xmlid
resolves to act_window, banner item shape when items exist. Summary
prints per-section counts so you can eyeball the entech state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JS: single FpQualityDashboard component + BannerCard / BannerItem /
SectionCard / SectionRow sibling sub-components in the same file.
Fetches /fp/quality/dashboard/snapshot, 60s poll, deep-link
?tab=certificates scrolls to section-cert via scrollIntoView.
XML: outer wrapper + banner + 6 sections (t-foreach over
state.snapshot.sections). Each section has id='section-<type>' so
the deep-link target works. SectionRow has overdue-conditional
class for red subtitle highlight.
SCSS: local tokens for urgent/good/section-head with light+dark via
$o-webclient-color-scheme branch. 135deg gradients matching the
plant kanban polish. Mobile breakpoint at 900px collapses banner
grid to 1 col and stacks row Open button.
OLD TABS array, selectTab, openTab, totalOpen, totalOverdue all
deleted. Old template's tab tiles + per-tab panels deleted. Existing
per-model kanbans untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers: missing-field critical-customer check returns empty without
crashing; computed_at is a valid ISO timestamp; every section ships
a non-empty open_kanban_xmlid in module.xmlid format.
(missing-model test from the plan omitted — patching env.__contains__
was unsafe; the in-self.env guard is already exercised by Tasks 2-4
in production behavior. The other 3 defensive tests still cover the
missing-field path, which is the more common scenario.)
Phase 1 backend complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_fetch_banner_candidates collects (overdue) OR (critical-customer +
open) records per type. _critical_customer_ids reuses partner.x_fc_rush
and partner.x_fc_vip flags when defined (gracefully no-ops when
absent). _critical_badge returns RUSH/VIP/AEROSPACE/AS9100 label
when the banner reason is critical-customer (no badge when overdue).
_build_banner ranks: overdue first by oldest, then critical-customer
by oldest, takes top 6, reports total_matching.
build() now collects banner candidates from every section in one
pass + invokes _build_banner once.
Tests cover overdue hold pickup, 6-cap with overflow count, and
all_clear when DB is empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id -> job_id.partner_id -> ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.
Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces /counts with /snapshot. Helper class FpQualityDashboardSnapshot
returns response with correct shape — banner placeholder + per-type
sections with open/overdue counts (reuses old counts endpoint
thresholds). Items + critical-customer banner come in Tasks 3-5.
Per CLAUDE.md Rule 13m, Model.sudo() on cross-module reads. Per
Rule 24 the in-self.env check guards missing-model paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests for empty-DB all-clear, canonical section order, and required
keys on each section. All fail until Task 2 lands the snapshot helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User goal: 'all quality related updates at glance, all the flagged
tasks need to show right here so the manager can quickly follow up
and complete the task'. Current dashboard is a tab-router (6 numeric
tiles + click-to-drill) — flagged tasks aren't visible without
navigation.
Design (Hybrid layout, approved):
- Red 'Needs Attention Today' banner on top (up to 6 items, 2x3 grid)
showing items that are overdue OR from critical customers
(x_fc_rush / x_fc_vip / aerospace). Green 'all caught up' when zero.
- Per-type sections below in QM-urgency order: Certs / Holds / NCRs /
RMAs / CAPAs / Checks. Each shows top 5 items inline + Open all
link to the existing kanban.
- Single 'Open ->' button per row -> opens record form via act_window.
No one-click action shortcuts (cert form is where Fischerscope +
sign-off prereqs are validated).
- Drop the existing 'Quality Overview' header strip entirely.
- 60s poll cadence preserved.
- ?tab=certificates deep-link from awaiting-cert notification email
preserved as scrollIntoView on the certs section.
Backend: replace /fp/quality/dashboard/counts with /snapshot. New
helper class FpQualityDashboardSnapshot builds banner + 6 sections in
one response. Cross-module reads sudo'd per Rule 13m; missing fields
gracefully degrade per Rule 13j defensive pattern.
Frontend: rewrite the OWL component. BannerCard + 6 SectionCards as
sub-components in the same JS file (not reused elsewhere yet).
Existing per-model kanbans untouched.
Self-review fixed 4 issues:
- _critical_customer_domain made per-type (was contradictory)
- OVERDUE_THRESHOLDS gained explicit use_due_date flag (CAPA branch)
- Template requirement called out: id='section-<type>' on each card
for the deep-link scrollIntoView to work
- doAction call shape disambiguated for xmlid vs full dict
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback after first deploy: 7 KPI tiles wrapped to second line
(grid was repeat(5, 1fr) but I had added 2 new ones), and the
controls felt cramped.
Layout fix:
- .kpi-strip grid: repeat(5, 1fr) → repeat(8, 1fr) so the row stays
one line and there's room for the new Awaiting QC tile.
Missing KPI added:
- Awaiting QC — fp.job.card_state='awaiting_qc' count. Operators
couldn't see when QC was blocking job close from the KPI strip
(only visible inside the column). Server-side count + filter
clause + matching filter chip.
Visual polish (all light + dark via existing token system):
- KPI tiles: padding 6→10px, value font 20→26px, label font 9→10px,
subtle 135deg linear-gradient bg per kind (urgent/warn/good/qc),
hover lifts the tile with translateY + shadow.
- Filter chips: padding 4/12→7/16px, font 11→13px, gradient bg,
active state has gradient blue + shadow.
- Search input: padding 5/10→9/14px, font 12→14px, focus ring.
- Toolbar buttons (Station/All Plant/Manager/Scan QR/Hand Off):
padding 5/10→8/14px, font 12→14px, gradients, hover lift.
Dark mode handled automatically — all gradients reference
$plant-* tokens which already have @if $o-webclient-color-scheme ==
dark global overrides in _plant_tokens.scss.
Version bump fusion_plating_shopfloor 19.0.34.0.0 → 19.0.34.1.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs caught + fixed during entech battle test:
1. _fp_check_finish_gates calling button_mark_done triggered the
step-completion gate prematurely (step still in_progress at
pre-super time). Pass fp_skip_step_gate=True alongside
fp_check_gates_only — we know the operator is about to finish
the last open step.
2. _fp_schedule_cert_activity used env.get('fp.notification.template')
for presence check. env.get returns an EMPTY recordset (falsy),
not None — 'if not Template: return' silently exited and no
activity was ever scheduled. Switch to 'in self.env' check
pattern + explicit indexing. CLAUDE.md Rule 24.
3. _fp_check_advance_after_cert_issue + _fp_check_regress_after_cert_void
used 'state != issued' as outstanding-cert count. This made
voided certs count as outstanding forever, so void+re-issue
cycles never re-advanced. Switch to per-type coverage check:
each required cert TYPE needs at least one issued cert.
Regress mirrors: only fire if a type loses all issued certs.
CLAUDE.md gains Rule 24 (env.get falsy empty recordset trap).
Rule 25 (mail.template parse-time validation) renumbered.
Battle test ALL PASS on entech admin DB:
10/10 steps green — auto-advance, kanban placement, activity
schedule + auto-resolve, ACL guard, cert issue advance, void
regress, re-issue advance, manual ship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>