Skip to content

#161 批量查阅: neutral read-only Query, 首页乙/墨与米 shell, interpretation publication status - #177

Draft
davidqyc wants to merge 11 commits into
mainfrom
claude/issue-161-batch-inspection
Draft

#161 批量查阅: neutral read-only Query, 首页乙/墨与米 shell, interpretation publication status#177
davidqyc wants to merge 11 commits into
mainfrom
claude/issue-161-batch-inspection

Conversation

@davidqyc

@davidqyc davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Implements Issue #161 from the accepted final Design handoff, following the
Coordinator's v2 Builder contract and the dual-audit adjudication
(comment 5553483948).

Do not merge. A fresh independent review is required first: this changes
real interpretation write and readback semantics.

Retrofit classification

Area Class
CredentialSession, TokenStore, RequestWindowScheduler, VocabularyTargetResolver, PreflightPlanner, WriteExecutor, PhraseCreateCore, ExecutionHistory store, CaptureReviewStore, PendingCaptureInbox REUSE
Domain, ConfirmationBinding, MaimemoTransport, HTTPTransport, ExecutionReceipt, CompanionViewModel, RehearsalMode EXTEND
editor / Preview / execution / History / Capture surfaces MOVE_UI_ONLY
ContentView split into per-route views SMALL_REFACTOR
AppRouter, QuerySessionStore, QueryReadRunner, Query core, Theme + components NEW_COMPONENT

No second credential stack, scheduler, resolver, History store, workflow
engine, query language or database was introduced.

What changed

Interpretation publication status (公开 / 未发布) — a value threaded
through the existing chain: preference → Preview snapshot → binding context
→ batch/binding digest → fresh authenticated preflight → request body →
authenticated readback → receipt. Not a new approval stack.

  • makePlan's pin to CompanionConstants.status becomes an explicit
    {PUBLISHED, UNPUBLISHED} allowlist; anything else fails closed before a
    digest, body or POST exists.
  • matchesIntendedState gains the status dimension, so same text + same tags
    • different status is 更新, never 一致.
  • A readback that does not reproduce the approved status is the existing
    intendedStateMismatchnotVerified, and is never retried.
  • CompanionConstants.status stays immutable for the phrase/legacy path.
  • ExecutionReceipt.interpretationStatus is optional and never backfilled;
    a committed golden pre-change archive proves old receipts still decode.

助记 (notes) readGET /open/api/v1/notes?voc_id=… on the same
authenticated transport and shared scheduler, from the current first-party
maimemo/memo-api-cli contract. Closed decoder, no mutation route, records
returned whole so Query's counting is a pure projection rather than a change
to shared transport semantics.

Neutral batch Query (read-only) — parser (newline / , / only; never
space, -, /; no semicolon in v1), advisory-only request budget derived
from the real scheduler windows, sequential row-major reads with one request
in flight, atomic resolver stage then atomic per-cell completion, stop /
resume / modify / back, local AND filter, detail from already-returned
objects, clipboard copy of the matched subset. Memory only.

One provider operation lane — the scheduler is a rate ledger, not a
single-flight proof, so credential validation, Preview, writes and Query now
take an explicit lane. Tests observe the fake transport's in-flight
high-water mark, not scheduler reservations.

Two-dimensional account identity — Keychain fingerprint plus an authority
generation advanced only by an explicit successful connect / replace /
remove. A background suspension, a same-Token foreground restore, a failed
candidate and a 401 all leave Query truth alone; a deliberate reconnect after
a rejection clears it even with the same Token.

Shell — 首页乙 / 墨与米 as semantic asset-catalog roles with Any / Dark /
Increased-Contrast variants; Settings owns account management; contextual
History filters one store and 清空历史 stays global and says so; Capture
keeps its lifecycle, its 抓词 bar and its three accessibility identifiers,
and accepting replaces the path so .write appears exactly once.

Verification

  • 406 unit tests, 10 UI tests, 0 failures (iPhone 17 Pro simulator).
  • Home verified visually in light and dark against boards 01-00 and 15-01.
  • No real Maimemo mutation, no real Token read or exposed, no iPhone
    Mirroring, no TestFlight build/version/upload change.

UNPUBLISHED_READBACK_ASSUMPTION=unverified_real_account — the first-party
schema and CLI prove the contract, but actual visibility/readback of an
UNPUBLISHED interpretation under the Owner's token is still not physically
proven. Verification is not weakened anywhere: a missing or mismatched
readback fails rather than being treated as success.

Two pre-existing issues repaired

  • #176 bumped CURRENT_PROJECT_VERSION to 4 but left an assertion pinned
    at 3, so BindingAndExecutionTests could not pass on main. Re-synced the
    assertion; no build/version/upload state changed.
  • The Share-join release gate matched 小黑鸟伴侣 by label alone, which now
    also matches Home's header. Tightened to the hittable row.

For the fresh reviewer

Priority attack surfaces are listed in the final Builder report.

🤖 Generated with Claude Code

davidqyc and others added 6 commits September 6, 2026 02:15
Characterize the existing behavior most at risk from the #161 renovation,
proven green against unmodified production code:

- a candidate Token that fails validation, or fails the Keychain save,
  leaves the old active connection and the stored Token untouched;
- the phrase binding context's expected status is the legacy shared
  PUBLISHED constant, which must not become a dynamic preference;
- 清空历史 clears both interpretation and phrase receipts from the one
  store, so the contextual presentation cannot narrow the destructive
  scope;
- a committed golden pre-change History archive — including a receipt
  written before contentKind/unconfirmed/diagnosticEnvironment existed —
  decodes in full, so the planned optional interpretation-status field
  cannot require a migration or an archive-version bump;
- the three Capture accessibility identifiers used by physical and
  simulator regression tests are pinned as a shared constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(Checkpoint 1)

Small domain/API/write-safety extension, ahead of any shell wiring.

Interpretation publication status (公开 / 未发布):
- new InterpretationPublicationStatus + device-local preference, mirroring
  WriteTagPreference; default .published reproduces today's behavior and
  today's binding digests byte-for-byte;
- the status is threaded through the existing chain — Preview snapshot →
  binding context → batch/binding digest → fresh authenticated preflight →
  request body → authenticated readback — not a new approval stack;
- makePlan's pin to CompanionConstants.status becomes an explicit
  {PUBLISHED, UNPUBLISHED} allowlist; anything else fails closed before a
  digest, body or POST exists;
- matchesIntendedState gains an intended-status dimension, so same text +
  same tags + different status classifies as 更新, never 一致;
- a readback that does not reproduce the approved status is the existing
  intendedStateMismatch → notVerified, and is never retried;
- CompanionConstants.status stays immutable for the phrase/legacy path;
  ExecutionReceipt gains an optional, never-backfilled interpretationStatus.

Notes (助记) read, strict and read-only:
- GET /open/api/v1/notes?voc_id=… on the same authenticated transport and
  shared scheduler, with a closed decoder; unrecognised envelope is a whole
  response rejection, unrecognised record an item rejection, nothing guessed;
- no note mutation route; records are returned whole, DELETED included.

Pure Query core: input parser (newline / , / , only — never space, -, / and
no semicolon in v1), the advisory-only request-budget helper derived from the
real scheduler windows, the count projection (0 is never unavailable), and the
local AND filter where unread/loading/unavailable satisfy no numeric predicate.

Also re-syncs one stale build-number assertion that #176 left pinned at 3
after bumping the project to 4; no build/version/upload state is changed.

370 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root ownership and coordination, then the headless Query engine. No UI yet.

One root owner, one operation lane:
- CompanionViewModel remains the single owner of the credential session,
  the shared RequestWindowScheduler, the write/Preview state and the Capture
  lifecycle entry; it now also owns an explicit provider operation lane;
- the scheduler is a rate ledger, not a single-flight proof, so credential
  validation, Preview, authorized writes and Query each take the lane before
  dispatching and cannot overlap. Tests observe the actual fake transport's
  in-flight high-water mark, not scheduler reservations;
- Query's whole access is a QueryReadLease: a transport built from the root
  owner's credential lease and the shared scheduler, plus the account
  identity. No Keychain, no second CredentialSession, no second scheduler,
  no write authority.

Account identity is two-dimensional, because a fingerprint alone cannot
express both required rules: the Keychain fingerprint, plus an authority
generation advanced only by an explicit successful connect / replace /
remove. So a background suspension, a same-Token foreground restore, a 401
and a failed candidate all leave account-derived truth alone, while a
deliberate reconnect after a rejection clears it even with the same Token.

QuerySessionStore is app/process-session scoped above the Query destination:
- sequential row-major reads, one request in flight, no concurrency;
- the resolver stage is atomic; after it, each content cell is atomic —
  completed cells are never re-read and unavailable cells never auto-retry;
- a run-generation token means a late response from a stopped or superseded
  run cannot mutate a newer result;
- stop preserves completed truth and marks the rest 未读, never 0;
- a global failure (401/network/429/server/schema) stops the batch and keeps
  completed rows; nothing is ever retried automatically;
- duplicate input rows cost one read and show the same truth;
- detail renders already-returned objects and issues no request;
- modify keeps the old result returnable until the first real edit;
- memory only — no persistence, no background continuation.

AppRouter is navigation and presentation only: one write destination, so
switching 释义/例句 is state rather than navigation and .write can never
appear twice on the path.

406 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frozen visual contract, wired to the already-tested domain and session
behavior. No write-safety mechanism changed; the surfaces moved.

- semantic design roles as asset-catalog colors with Any / Dark / Increased
  Contrast variants, referenced through a small Theme namespace so no view
  carries a hex literal;
- Home 首页乙: two ink tiles, one ivory 批量查阅 card, one 设置 pill, and
  nothing else — no account row, metrics or tabs;
- Settings owns account management: connect / replace / remove with a
  destructive confirmation whose copy states the real scope, one 录入偏好 row
  with a 公开 · 标签 n/3 summary, and About;
- 录入偏好: the 释义发布状态 segment and 词签 chips in a native adaptive grid
  that falls back to a native checkmark list at accessibility sizes;
- the write surface keeps every existing editor/Preview/execution builder and
  gains only the 连接状态 line, the contextual history pill and the frozen
  detail labels 现有 / 拟写入 / 标签 / 状态;
- contextual History filters one store by the mode it was entered with;
  清空历史 stays global and says so;
- Query: input with the request-budget advisory, the progressive results table
  with the neutral numeric grammar, the local filter sheet with present-only
  reasons, row detail from already-returned objects, copy, stop/resume and the
  running interrupt dialogs;
- Capture keeps its proven lifecycle and its three accessibility identifiers,
  and accepting replaces the path so .write appears exactly once.

The root view still owns the one CompanionViewModel, the scenePhase ordering,
the Capture pickup gate and all three write confirmations. The app-scoped
QuerySessionStore lives above the Query destination, so Home ↔ 批量查阅
restores the result with no request.

The source-scanning guard is re-homed rather than weakened: UIPasteboard is
now allowed in exactly one file (the Query results view, for 复制当前 N 项)
and forbidden everywhere else, and the private-sounding label for UNPUBLISHED
is asserted absent from all production sources.

406 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On-simulator regression for the retrofitted shell, plus the three real
accessibility defects it caught and fixed.

Coverage:
- Home exposes exactly the four frozen entries and none of the things the
  design froze out (no account row, no History summary, no tab bar);
- Settings owns account management, reaches 录入偏好 and 关于, and the root
  summary reflects both preferences;
- 释义 ↔ 例句 is a state change, not navigation: the title and history pill
  follow it and a single back returns to Home, so .write is entered once;
- contextual History is reached from the write surface and returns;
- Query input states, the disconnected gate, live parse feedback, and the
  app-scoped store surviving a Home round-trip with no work;
- at accessibility Dynamic Type the tag grid falls back to a native list,
  the 3/3 rule and its hint copy still hold, and disabled why-lines survive.

Accessibility fixes the tests exposed:
- a grouped row wrapped its Button in an accessibility element, producing a
  phantom element that absorbed taps without activating the row — and
  forcing `.accessibilityElement` onto a Button drops its button trait
  entirely. Rows now set label/value on the Button itself;
- the segment control's group label overwrote every segment's own name, so
  VoiceOver announced "录入模式" three times instead of 释义 / 例句. Segments
  keep their names and the group is a `.contain` container;
- Home tiles and the Query card led with a run-on combined phrase; they now
  announce the name and carry the supporting lines as the value.

The Query input's separator rules are the field's VoiceOver hint, which
XCUITest cannot read back, so that copy is pinned in the source guard with
the other frozen Query strings.

Also restores the Capture review's 抓词 navigation bar and its nav-bar 取消,
matching board 15-04 and keeping the existing physical/simulator Capture
regression tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…header

#161's Home displays `小黑鸟伴侣` as its header, and that header sorts ahead
of the Share Sheet's extension row in the accessibility hierarchy, so the
release gate's label-only query began resolving to a view the modal sheet was
covering.

Tightened rather than loosened: the row is now chosen by hittability, which
is precisely "the row the Owner can actually tap" and reintroduces no
coordinate or row-position assumption. `hittable` is not a valid XCUIElement
predicate key path, so the choice is made in code over the matching elements.

Full suite green: 406 unit tests, 10 UI tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physical current-version evidence received before PR adjudication exposes a pre-existing Phrase post-write recovery defect that PR #177 does not currently repair.

Observed flow from Owner video:

  • Preview: 5 phrases, 新建 5 / 一致 0 / 阻断 0.
  • Execution: 新建成功 1 / 未确认 1 / 未执行 3; stop-after-unconfirmed behavior is correct and must remain.
  • Later re-Preview: shipping invoice and factory site both become 阻断 / 无法安全读取例句状态; remaining three stay 新建.
  • Maimemo UI independently shows the shipping invoice phrase actually exists with the intended content, so re-Preview recovery is failing on an already-created record rather than truthfully converging to 一致.

Fresh first-party increment: maimemo/memo-api-cli currently defines Phrase.tags as free-form string[], and its official tests use arbitrary tags such as greeting / updated. Current iOS phraseTags(_:) instead rejects any returned tag outside the app's canonical 22 write-preference tags. This is a strong root-cause candidate for a post-create record becoming itemResponseRejected -> READ_FAILED; the Owner UI also shows a 我创建 chip, but we must not assume that exact UI badge is in the API tags array without raw-response proof.

Important separation: outbound/write tag selection may remain restricted to the canonical app tags. The read decoder should not reject an otherwise safe Phrase merely because the provider returns an additional safe tag string that is outside the app's write-preference vocabulary. PhraseRecord.hardMatches does not use tags for write-target identity; tags are an observation layer.

MERGE_GATE=BLOCK_PENDING_PHRASE_POSTWRITE_READ_RECOVERY

Before merge, either prove a different exact root cause or repair the read contract with focused regression coverage. Minimum high-value test: a valid returned phrase with a bounded/safe non-canonical tag must decode and remain classifiable instead of becoming READ_FAILED. Then perform a read-only physical re-Preview on the already-created Owner items; no new Maimemo mutation is needed.

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinator note: the fresh provider evidence is specifically:

  • maimemo/memo-api-cli/src/types/phrase.ts: Phrase.tags is string[] (no closed tag enum).
  • maimemo/memo-api-cli/test/commands/phrase.test.ts: provider-owned fixtures create/update valid phrases using tags such as greeting and updated.

Decision impact: canonical 22-tag validation remains appropriate for outbound user preference/write input, but it is too strict as a universal inbound provider phrase decoder contract. Keep those concerns separate in any repair.

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physical evidence source is Owner-supplied current-version screen recording + Maimemo screenshot from 2026-09-06. Preserve as external/manual evidence only; do not commit the private recording or screenshots into the repo.

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh external decision increment for this blocker is complete and decision-relevant: current first-party Maimemo Phrase schema/tests prove inbound tags are not restricted to the app's canonical write-tag set. The repair/review lane may proceed from this frozen evidence; no broad provider research is needed.

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physical regression gate — phrase post-write recovery

Owner supplied a current-production physical recording plus Maimemo UI evidence showing a real safety-recovery defect: a phrase batch wrote at least the first two items, but a later Preview classified those already-created items as 阻断 / 无法安全读取例句状态 instead of recovering to 一致.

Fresh first-party Maimemo evidence materially narrows the likely cause: Phrase.tags is an open string[] in the current official memo-api-cli type, and the official phrase integration tests use values such as greeting and updated. The current iOS decoder rejects any inbound phrase tag outside the app's local 22-tag write-preference catalog. That couples outbound preference validation to inbound provider schema and can reject an otherwise valid existing phrase.

This review does not claim the screenshot label 我创建 is definitely a raw API tag; raw payload was not captured. The decoder contract is independently over-restrictive from first-party evidence and must be repaired regardless.

Required repair direction:

  • keep the local 22-tag/max-3 rules for what this app lets the user write;
  • inbound phrase decoding must accept safe provider-returned string tags without requiring membership in that local catalog;
  • keep fail-closed type/control-character/body-size validation;
  • preserve hardMatches identity semantics (tags are observations, not phrase identity);
  • add a regression proving a matching PUBLISHED phrase with a safe non-local tag decodes and Preview classifies it 一致 rather than READ_FAILED;
  • no mutating POST is needed to verify this repair.

MERGE_GATE=BLOCK_PENDING_PHRASE_POSTWRITE_READ_RECOVERY
PHYSICAL_CANARY_AFTER_REPAIR=read-only re-Preview of already-created items; expected first written items -> 一致, never duplicate POST.

@davidqyc davidqyc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair dispatch checkpoint — 2026-09-06

EXTERNAL_INCREMENT_ROUND=XHN-161-PHRASE-POSTWRITE-RECOVERY-20260906
SEARCH_DATE=2026-09-06
DECISION_ATTACKED=Whether the current post-write re-Preview block is a provider-visibility problem or an app-side read/decoder recovery defect.
FRESH_EXTERNAL_SOURCE=https://github.com/maimemo/memo-api-cli/blob/main/src/types/phrase.ts ; https://github.com/maimemo/memo-api-cli/blob/main/test/commands/phrase.test.ts
NEW_EXTERNAL_FACT=Current first-party Phrase.tags is an open string[] and official tests use safe tag strings such as greeting/updated outside this app's local 22-tag write-preference catalog.
LOCAL_CURRENT_FACT=Current iOS phrase decoder rejects inbound tags unless every tag belongs to that 22-tag local catalog; PhraseRecord.hardMatches itself does not use tags for phrase identity.
DECISION_IMPACT=Run a narrow same-branch repair: separate inbound provider tag decoding from outbound local tag preference validation, add a post-write/re-Preview regression, keep PR #177 Draft/unmerged, then perform a read-only physical re-Preview of the already-created canary items.
EXTERNAL_INCREMENT_STATUS=PASS
RETURN_BRIDGE_STATUS=READY_FOR_OWNER_RELAY
TASK_ID=XHN-161-PHRASE-POSTWRITE-RECOVERY-20260906
TARGET_AGENT=Claude Code / Opus 5 / Extra / Standard / Single Agent
TARGET_CONVERSATION=CONTINUE_EXISTING
TARGET_CONVERSATION_TITLE=【XHN】#161 Retrofit Implementation
TARGET_WORKSPACE=/Users/david/Documents/GitHub/momo-moreEfficient
TARGET_BRANCH=claude/issue-161-batch-inspection
TARGET_PR=177
EXPECTED_PRE_REPAIR_HEAD=622c769c90c1b694b7da2cd204cab7c1f0a71dca
PROMPT_PREPARED=yes
PROMPT_VISIBLE_TO_OWNER=unknown_until_actual_delivery
EXTERNAL_AGENT_DISPATCHED=unknown
AGENT_RUN_COMPLETED=unknown
RESULT_RETURNED=no
RESULT_INGESTED=no
DUPLICATE_DISPATCH_ALLOWED=no_by_default
RETURN_CAN_RESUME_IN_FRESH_CHAT=yes
CURRENT_AUTHORITY=PR #177 reviews + Issue #161

Do not merge PR #177 before this repair and the bounded read-only physical canary are adjudicated.

…161)

An already-created phrase failed to recover on re-Preview: it came back as
阻断 / 无法安全读取例句状态 instead of 一致.

Root cause: MaimemoTransport.phraseTags required every returned tag to be a
member of the app's local `documentedTags` write catalog, and capped the array
at that catalog's length. That conflates two different contracts. The local
22-item catalog governs what the user may SELECT and WRITE; the provider's
inbound `Phrase.tags` is an open `string[]` (first-party memo-api-cli
src/types/phrase.ts), and its own phrase integration fixtures return values
such as `greeting` / `updated` that are outside the local catalog.

A valid provider record therefore failed to decode with itemResponseRejected,
which PhrasePreflightPlanner catches as READ_FAILED and renders as 阻断. The
same decoder backs the phrase readback and the #161 Query 例句 cell, so the
defect also degraded post-write confirmation and read-only counts.

Fix: inbound phrase tags must be a String array whose elements pass the same
bounded safe-string check already used for `origin`; no catalog-derived value
or count limit is imposed on the provider. Everything else in the phrase
decoder is unchanged — identifiers, duplicate-ID rejection, phrase and
interpretation text safety, origin, highlight, the status allowlist and the
whole-response size cap all still fail closed. Tags remain observational and
are still not part of PhraseRecord.hardMatches, so a matching phrase with a
different provider tag classifies alreadyMatching with a .tagsDiffer
observation. Interpretation inbound tag semantics are deliberately untouched.

Tests: inbound non-local tags decode (including an array longer than the local
catalog); malformed shapes and unsafe strings still fail closed; an
already-created phrase with a non-local tag recovers to .alreadyMatching / 一致
through the real decoder + preflight rather than READ_FAILED; the local
outbound tag preference stays closed at 22 items / max 3; and the Query phrase
cell still counts truthfully. Verified as a negative control: the two recovery
tests fail against the pre-repair guard.

411 unit tests pass. UI tests not run — no production UI code changed.
Physical read-only re-Preview canary still pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@davidqyc

davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Repair — phrase post-write recovery (936cf81)

Narrow repair on this branch for the regression the Owner reproduced on device: after a partial phrase run, the already-created phrase did not recover to 一致 on re-Preview — it became 阻断 / 无法安全读取例句状态.

The safety stop after the uncertain outcome was correct and is unchanged. The defect was on the read side.

Root cause

MaimemoTransport.phraseTags(_:) required every inbound tag to be a member of this app's local documentedTags write catalog, and capped the array at that catalog's length:

tags.count <= documentedTags.count,
tags.allSatisfy({ documentedTags.contains($0) })

That conflates two different contracts. The local catalog governs what the user may select and write; it says nothing about what the provider may return.

Failure chain — note that itemResponseRejected is deliberately not in abortsReadPlan, so it degrades to a per-item block rather than stopping the batch:

provider returns a valid phrase with a tag outside the local catalog
-> phraseTags throws CompanionError.itemResponseRejected
-> MaimemoTransport.phrases(vocabularyID:) rejects the whole record
-> PhrasePreflightPlanner generic catch -> reason "READ_FAILED"
-> 阻断 / 无法安全读取例句状态

phrases(vocabularyID:) is shared by three call sites, so the same decoder also backed the post-POST readback (PhraseCreateCore.confirmPhrase) and the #161 Query 例句 cell (QueryReadRunner). The observed 未确认 on the second item is consistent with the same mechanism reaching the readback path, though that one is not independently proven from the evidence supplied.

First-party contract

So local outbound preference catalog != provider inbound phrase tag schema. The decoder was independently too strict against the provider contract; no repair is hard-coded around the 我创建 label seen in the Owner's screenshot, since we have no raw payload proving that label appears in Phrase.tags.

Exact fix

One function. Inbound tags must be a String array whose elements pass the same bounded safe-string check already used for origin:

guard let tags = raw as? [String],
      tags.allSatisfy({ safeSingleLine($0, maximumCharacters: 256) != nil })

No catalog-derived value or count limit is imposed on the provider; the array stays bounded by the existing whole-response size cap in jsonObject.

Unchanged, still failing closed: safe identifiers, duplicate-ID rejection, phrase text, interpretation text, origin, highlight decoding, the phrase status allowlist (PUBLISHED / DELETED), response-size cap, no mutation retry, and the whole Preview/write safety floor. Tags are not part of PhraseRecord.hardMatches and were not made so — identity remains English + Chinese + optional origin + PUBLISHED, so a matching phrase with a different provider tag classifies alreadyMatching and reports .tagsDiffer as an observation. Interpretation inbound tag semantics are deliberately untouched (documentedTags still gates that path).

Tests

Coverage Result
Inbound non-local tags decode (["greeting"], ["updated"], mixed, and a 40-element array longer than the local catalog) PASS
Malformed tag shapes/values still fail closed (non-array, non-String element, nested array, empty, whitespace-only, control character, newline, over-length) PASS
Already-created phrase with a non-local tag recovers to .alreadyMatching / 一致 through the real decoder + PhrasePreflightPlanner — not READ_FAILED PASS
Local outbound tag preference still closed: 22 items, max 3, unknown tag rejected, duplicates rejected PASS
Query 例句 cell still counts truthfully and shows the record in detail PASS
Existing privacy test retained, now driven by a genuinely malformed tag value carrying the sentinel PASS

Negative control: with the pre-repair guard restored, testInboundPhraseTagsOutsideLocalWriteCatalogDecode and testAlreadyCreatedPhraseWithNonLocalProviderTagRecoversToAlreadyMatching both fail — the tests close the exact observed failure mode.

TARGETED_TESTS   PhraseCreateCoreTests 47 + QuerySessionStoreTests 26 -> 73 executed / 0 failures
FULL_UNIT_TESTS  411 executed / 0 skipped / 0 failures
UI_TESTS         not run — decoder-only repair, no production UI code changed
REAL_MAIMEMO_MUTATION_PERFORMED   no
IPHONE_MIRRORING_USED             no

Still pending

Physical read-only canary remains required and is a Coordinator gate — no write is needed to validate this. Re-Preview the same batch against the already-created canary phrases:

already-created first item(s) -> 一致
later never-written items     -> still 新建, subject to actual server truth
no duplicate POST

PR stays Draft; not merged.

davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Return Bridge — read-only physical re-Preview canary

RETURN_BRIDGE_STATUS=READY_FOR_OWNER_RELAY
TASK_ID=XHN-161-PHRASE-RECOVERY-PHYSICAL-READONLY-CANARY-20260906
TARGET_AGENT=Claude Code / existing Opus 5 session
TARGET_CONVERSATION=CONTINUE_EXISTING
TARGET_CONVERSATION_TITLE=【XHN】#161 Retrofit Implementation
TARGET_WORKSPACE=/Users/david/Documents/GitHub/momo-moreEfficient
CANDIDATE_PR=177
CANDIDATE_HEAD=936cf8123395238c0fada03e8f04faf7667f8fd9
REPAIR_RESULT=PASS_UPDATED_PR
REPAIR_COMMIT=936cf8123395238c0fada03e8f04faf7667f8fd9
AUTOMATED_EVIDENCE=73 targeted / 0 failures; 411 full unit / 0 failures
PHYSICAL_CANARY_MODE=READ_ONLY_PREVIEW_ONLY
REAL_MAIMEMO_MUTATION_ALLOWED=no
IPHONE_MIRRORING_ALLOWED=no
CANARY_INPUT=Owner's same phrase batch that previously produced shipping invoice/factory site post-write recovery block
EXPECTED_RECOVERY=already-created phrases become 一致 instead of READ_FAILED/阻断; never-written later phrases remain 新建 subject to actual provider truth
PROMPT_PREPARED=yes
PROMPT_VISIBLE_TO_OWNER=unknown_until_actual_delivery
EXTERNAL_AGENT_DISPATCHED=unknown
AGENT_RUN_COMPLETED=unknown
RESULT_RETURNED=no
RESULT_INGESTED=no
DUPLICATE_DISPATCH_ALLOWED=no_by_default
CURRENT_MERGE_GATE=BLOCK_PENDING_READ_ONLY_PHYSICAL_REPREVIEW
RETURN_CAN_RESUME_IN_FRESH_CHAT=yes

The repair root cause is accepted provisionally from the returned Builder result: inbound provider Phrase.tags was incorrectly gated by the app's local outbound tag catalog. PR #177 remains Draft/unmerged until this physical read-only canary closes the observed production recovery defect.

davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Physical read-only re-Preview canary — PASS

Exact candidate on device:

PR=177
HEAD=936cf8123395238c0fada03e8f04faf7667f8fd9
BUILD=1.0 (4) Release
REAL_MAIMEMO_MUTATION_DURING_CANARY=no

Owner pasted the same phrase batch from the failed production run and tapped Preview exactly once.

Observed physical result:

TOTAL=5
新建=3
一致=2
阻断=0

shipping invoice = 一致
factory site      = 一致
bulk email        = 新建
job vacancy       = 新建
fend off          = 新建

This exactly closes the targeted GET-only recovery defect:

already-created phrase
-> repaired inbound phrase decoder
-> subsequent authenticated read / Preview
-> 一致

The prior bad path 阻断 / 无法安全读取例句状态 is no longer reproduced on the real account/device.

The Owner did not tap the write/confirm action after this Preview, so this physical canary itself introduced no duplicate phrase POST.

PHYSICAL_PHRASE_POSTWRITE_RECOVERY=PASS
MERGE_GATE_PHYSICAL_REPREVIEW=CLOSED
NEXT_GATE=FRESH_INDEPENDENT_PR_REVIEW
PR_STAYS_DRAFT=yes
MERGE_AUTHORIZED=no

Note for the next review: the screenshot also visibly shows the write surface in a 需重新预览后才能写入 state after the read-only canary. That did not affect the targeted recovery verdict above, but fresh review should verify whether this stale/write-validity presentation is expected under the current candidate state or represents a separate regression before merge.

davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Clarification — stale Preview concern withdrawn

Owner clarified the earlier screenshot showing 需重新预览后才能写入 was taken after the phone had been locked / switched through mirroring for demonstration, and the Owner then explicitly ran Preview again.

Current physical screenshot after that fresh Preview shows:

新建 3
一致 2
阻断 0
shipping invoice -> 一致
factory site -> 一致
bulk email -> 新建
job vacancy -> 新建
fend off -> 新建
bottom action `新建 3 条例句` enabled

Therefore:

PHRASE_POSTWRITE_RECOVERY_PHYSICAL_CANARY=PASS
STALE_PREVIEW_FINDING=WITHDRAWN_AS_FALSE_ALARM_FROM_LOCK/MIRRORING_SEQUENCE
NO_NEW_STALE_STATE_BUG_OPENED=yes
REAL_MUTATION_DURING_CANARY=no

The physical repair gate is closed. PR #177 remains Draft only because the planned fresh independent PR review is still required before merge.

davidqyc commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

New physical evidence after recovery PASS — residual 未确认 still reproducible

Owner accidentally proceeded with the now-enabled 新建 3 条例句 action on exact physical candidate 936cf8123395238c0fada03e8f04faf7667f8fd9.

Observed execution result:

recognized total = 5
new items before execution = 3
execution summary = 成功 1 / 未确认 1 / 未执行 1
execution stopped after uncertain outcome
no evidence of duplicate POST

This does not invalidate the phrase decoder recovery fix: the earlier physical read-only re-Preview already proved the old 阻断 / 无法安全读取例句状态 regression is fixed (shipping invoice + factory site recovered to 一致, 阻断 0).

It does prove, however, that the original 未确认 phenomenon has an additional cause beyond the inbound-tag decoder bug, or at least is not eliminated by that decoder repair.

Current code already uses up to 3 GET-only phrase readback attempts after POST. Those reads are rate-window paced, but rate-window pacing is not itself a deliberate visibility-delay backoff when the request window has room. Therefore a short post-write visibility lag remains a plausible mechanism, but not proven from current evidence. Another readback-specific mismatch remains possible.

Fresh first-party scan: current Maimemo public docs/API CLI do not document a phrase-specific read-after-write visibility guarantee or required delay. General Maimemo documentation does acknowledge that some synchronized data can appear with delay, but that is not sufficient evidence to attribute phrase API readback behavior. So no timing patch is authorized yet.

Next gate — GET-only discrimination, no further write

Use the just-created server truth as a canary. Do not execute any more writes.

After a short bounded settle, perform exactly one fresh read-only Preview of the same 5-item batch.

Expected discriminating result if the uncertain POST actually succeeded and is now visible:

previously-existing + first successful new item + previously-unconfirmed new item -> 一致
never-attempted final new item -> 新建
阻断 -> 0

If the previously-unconfirmed item becomes 一致, classify the remaining problem as same-run readback confirmation window insufficient / provider visibility timing still unresolved and design a bounded GET-only recovery-window improvement before merge.

If it does not become 一致, inspect the exact Preview classification/reason before changing retry timing.

PHRASE_DECODER_RECOVERY=PASS_PHYSICAL
RESIDUAL_UNCONFIRMED=REPRODUCED_ON_REPAIRED_HEAD
NEW_MUTATION_AUTHORIZED=no
NEXT_ACTION=ONE_BOUNDED_READ_ONLY_REPREVIEW_AFTER_SETTLE
MERGE_GATE=BLOCK_PENDING_RESIDUAL_UNCONFIRMED_DISCRIMINATION

davidqyc commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Fresh independent review dispatch checkpoint — 2026-09-06

Physical closure before review:

CANDIDATE_HEAD=936cf8123395238c0fada03e8f04faf7667f8fd9
PHRASE_DECODER_RECOVERY_PHYSICAL_CANARY=PASS
UNCERTAIN_OUTCOME_FAIL_CLOSED=PASS_OBSERVED
LATER_GET_RECONCILIATION=PASS
AUTO_MUTATION_RETRY=none
RESIDUAL_UNCONFIRMED_ROOT_CAUSE=unknown_review_only
EVENTUAL_CONSISTENCY_HYPOTHESIS=WITHDRAWN_NOT_PROVEN
NO_MORE_PHYSICAL_ACTION_REQUIRED=true

Fresh external routing increment: current OpenAI first-party docs position GPT-6 Astra as the most capable model for hard end-to-end reasoning/coding, support up to max reasoning, and list multi-agent orchestration among current capabilities. This PR has four genuinely independent long read-only review lanes, satisfying the live agent-skills multi-agent ROI gate.

Sources:

EXTERNAL_INCREMENT_ROUND=XHN-161-PR177-FRESH-REVIEW-ROUTING-20260906
DECISION=Use GPT-6 Astra Ultra for fresh independent recall-max review
REVIEW_MODE=RECALL_MAX_PROGRESSIVE
LANES=write-safety/publication/uncertain-outcome; provider/query; state-concurrency-identity; retrofit-history-capture-navigation
FINDINGS_AGGREGATION=UNION_NOT_VOTE
BUILDER_CHANGES=HOLD_PENDING_REVIEW

Return Bridge:

RETURN_BRIDGE_STATUS=READY_FOR_OWNER_RELAY
TASK_ID=XHN-161-PR177-FRESH-RECALL-MAX-REVIEW-20260906
TARGET_AGENT=Codex GPT-6 Astra Ultra
TARGET_CONVERSATION=NEW_CONVERSATION
PROMPT_ARTIFACT=momo_161_PR177_Fresh_Recall_Max_Review_GPT6_Astra_2026-09-06.zip
PROMPT_SHA256=b45a5f47038bba3a9cb1721368d9465b012c0db32dde7fddb2dd09b8a963e668
PROMPT_PREPARED=yes
PROMPT_VISIBLE_TO_OWNER=unknown_until_actual_delivery
EXTERNAL_AGENT_DISPATCHED=unknown
AGENT_RUN_COMPLETED=unknown
RESULT_RETURNED=no
RESULT_INGESTED=no
DUPLICATE_DISPATCH_ALLOWED=no_by_default
RETURN_CAN_RESUME_IN_FRESH_CHAT=yes
CURRENT_AUTHORITY=PR #177 @ 936cf8123395238c0fada03e8f04faf7667f8fd9

Review is strictly read-only: no code edits, no PR mutation, no merge, no real Maimemo calls, no Token access, no iPhone Mirroring. Reviewer must return one actual downloadable Review ZIP plus candidate ledger for Coordinator progressive adjudication.

davidqyc and others added 4 commits September 6, 2026 19:42
The Coordinator's accepted repair batch for PR #177: C-02, C-01 and the
C-03 test-evidence gap. No new credential, scheduler, executor or recovery
stack; nothing outside that batch is touched.

C-02 — a scene interruption during a Query run could falsely disconnect.
Query owned the one provider operation lane without setting any of the
existing lifecycle-busy flags, so `enterBackground()` tore the transient
credential down underneath it. The `.active` transition that followed then
found `restoreCredentialIfAvailable()` unable to acquire the lane, and
ScenePhase promises no second transition, so the app could stay falsely
disconnected while active.

Query now participates in that same existing coordination: a run holding
the lane defers the teardown exactly as an in-flight Preview or authorized
write already does, so the credential is simply still there when the scene
returns. If the run instead finishes while the app is away, releasing the
lane settles the owed teardown, and the next `.active` restores normally.
The three duplicated drain bodies collapse onto one
`settleDeferredBackgroundTeardown()` helper — one flag, one mechanism, not
a second deferred-work queue. Account identity semantics are unchanged.

C-01 — a Query 401 never reached the root. The root already had
`handleQueryAuthenticationRejection()`, but nothing production-side called
it: the store recorded `.globalFailure(.authenticationRejected)` while root
`isConnected` stayed true and `beginQueryRead()` could mint another lease
from the same rejected credential. `QueryReadLease` — the narrow seam the
root already hands Query — gains a once-per-lease
`reportAuthenticationRejection()`, and `finishWithGlobalFailure` calls it
for `authenticationRejected` only. A decode failure, a transport error and
a rate limit stay ordinary read failures. Completed Query truth survives,
unfinished cells stay 未读, identity is untouched, and nothing retries.

C-03 — `stopDispatching()` cleared `activeTask`, so `awaitRunCompletion()`
became a no-op and stop/late-response assertions ran against a signal that
answered instantly. The store now also keeps `lastDispatchedRunTask`, which
stop does not clear, so awaiting a stopped run really waits for it to unwind
and release its lease. It carries no run authority; `runGeneration` alone
still decides whether a late response may apply.

Tests: 9 new lane/lifecycle tests plus one store test. The C-01 proof runs
a real 401 through the production transport, runner and store rather than
calling the root handler by hand; the C-02 proofs step a gated transport
through both interruption orderings. A negative control that reverted only
the three behavioural lines failed 6 of them.

focused 47/0, affected 148/0, full unit 421/0, simulator UI 10/0.
No real Maimemo call, no real Token read, no physical-device run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… scroll position (#161)

Bounded PR #177 repair of three Coordinator-adjudicated findings:

- A-01: interpretation Preview and both native write confirmations
  (single-group and mixed-batch) now state the exact intended 公开/未发布
  status, derived from the bound PreviewSnapshot rather than the live
  publicationPreference, for CREATE-only and mixed plans alike.
- B-03: the calm-tier Query duration advisory now says the minutes figure
  assumes available quota and that recent large batches on the shared
  5-hour RequestWindowScheduler allowance can make the real wait longer,
  instead of reading like a precise ETA.
- D-01: QueryView's result list uses native SwiftUI
  scrollTargetLayout()/scrollPosition(id:anchor:) so ordinary scrolling
  keeps QuerySessionStore.scrollAnchor current, not only a row tap, so
  修改 -> 返回结果 restores the actual reading position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t target

Several CompanionViewModelTests-adjacent test helpers constructed
CompanionViewModel without overriding preferenceDefaults, so they shared the
app's real .standard UserDefaults domain. Under randomized execution order,
one test's WriteTagPreference/InterpretationPublicationPreference write (or
leftover state from a prior run) could leak into another test's initial
selectedTags/publicationPreference.

Give every test-constructed CompanionViewModel its own isolated
UserDefaults(suiteName:) domain by default (TestSupport.isolatedPreferenceDefaults()),
wired through every connectedModel-style helper and every direct construction
across the test target. Add a negative-control test proving the mechanism:
two models sharing an explicit suite observe each other's writes; two
independently isolated models never do. Enable randomized execution order for
the MomoMoreEfficientTests scheme target so this class of bug surfaces going
forward (note: xcodebuild CLI did not appear to honor this flag in local
verification runs; it is a legitimate Xcode scheme setting regardless).

No production source changed. Verified: focused suites green, and the full
MomoMoreEfficientTests target green across 5 consecutive runs (425/425,
0 failures, 0 crashes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ariance

Capture Gate run 34130642290 failed both attempts at a flat 10s
waitForExistence for the extension's own title after tapping the Share
Sheet row. The identical test/build passed one run earlier (34057604549,
head d0c427c) with that same title appearing in ~4s, and the only commit
between that green head and this one (4dc2a53) is verified test-only
(CompanionViewModel preference isolation; no ShareExtension/UI-test/app
files touched). Test suite order is identical between both runs, and
there is no crash/OOM signal in the failing run's log.

This points to extension-process cold-start cost on a loaded shared CI
runner, not a broken or structurally unobservable extension UI: the host
app's XCUIApplication instance does reliably see the extension's UI when
given enough time, which the historical passing runs already prove.

Widen the two timeouts governing that observation window (appear after
tap, disappear after save) from 10s to 20s. Nothing else changes: same
real Share Sheet, same real Share Extension, same exact-payload proof.

Verified locally on Xcode 26.6 / iOS 26.5 Simulator: the four focused
Capture Gate suites (ShareCaptureTests, CaptureReviewTests,
CapturePendingReviewUITests, CaptureShareSheetUITests) all pass, 0
failures, including the full real Share Sheet -> extension -> save ->
Capture Review exact-payload path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant