44categories, and URLs only, never secrets — then triage in the browser editor,
55either one item at a time (wizard) or all at once (bulk), before sealing.
66
7- Status: SPEC. Written 2026-07-28. Owner: Jamie. Register row: EF-ISS-8.
7+ Status: ** P1 BUILT 2026-07-29** (pull + mapper + tests; findings in §11).
8+ P2-P4 still spec. Written 2026-07-28. Owner: Jamie. Register row: EF-ISS-8.
89
910---
1011
@@ -126,8 +127,18 @@ Every field is a *guess presented for confirmation*, pre-filled never
126127auto-final in wizard mode. Other mapped fields:
127128
128129- ` provider ` ← item title
129- - ` identifier ` ← ` 1Password > {title} ` — the pointer style the design wants;
130- never a number, never a URL with query strings
130+ - ` access_pointer ` ← ` 1Password > {title} ` — the pointer style the design
131+ wants (and the exact example the editor already shows for this field).
132+ ** Corrected in P1:** the first draft of this spec put the pointer in
133+ ` identifier ` , which is the field for a last-4 or a reference; the pointer
134+ belongs in ` access_pointer ` , and dedup (§5) is stronger there because the
135+ owner edits ` identifier ` (adding last-4 digits) and would break the key.
136+ - ` identifier ` ← the URL host if the item has one, else the title. A
137+ searchable reference, never a number, never a path or query string.
138+ - ` priority ` ← per the table; the schema's middle value is ` normal ` , not
139+ "medium". A finance heuristic hit (§9.5) raises it to ` high ` .
140+ - ` ownership ` ← ` sole ` (the commonest case, and a required field — the
141+ wizard shows it for correction)
131142- ` status ` ← ` active ` ; ` last_confirmed ` ← today (the owner is looking at it
132143 right now)
133144- ` action_notes ` ← wizard: owner-written or a sensible template; bulk: the
@@ -138,26 +149,49 @@ not the engine — see the finance-heuristic ranking consequence there.
138149
139150## 5. Dedup contract
140151
141- - Match key: normalised ` identifier ` equal to ` 1Password > {title} `
152+ - Match key: normalised ` access_pointer ` equal to ` 1Password > {title} `
142153 (case-insensitive, whitespace-collapsed). Already-present matches are
143154 filtered out of the candidate list before either mode shows anything.
155+ The importer also matches the key against existing ` identifier ` values,
156+ so registers written before this feature still dedup.
144157- Soft warning on near-misses: candidate title ≈ existing ` provider `
145158 (exact match after normalisation) shows "possibly already listed as A007"
146159 instead of silently duplicating.
160+ - ** Title clashes (P1 finding, §11):** two vault items can carry the same
161+ title, and then they carry the same pointer — so next year's re-import
162+ would hide the second one. The mapper flags those candidates with
163+ ` title_collisions: N ` ; the wizard (P2) must ask for something
164+ distinguishing in ` identifier ` (last 4 digits) rather than silently
165+ accepting twins. On a real vault this is not an edge case: 178 of 563.
147166- Dedup is what makes the review-time re-import (§3) work with zero extra
148167 machinery.
149168
150169## 6. Architecture
151170
152171Three small pieces, following the existing edit-server pattern:
153172
154- 1 . ** ` scripts/import-1password.sh ` ** — the pull. Checks ` op ` exists and is
155- signed in, runs ` op item list --format=json ` (plus ` op vault list ` for the
156- picker), maps to a manager-agnostic ** candidate JSON** on stdout:
157- `{source, pulled, vaults:[ ...] , candidates:[ {title, category, url_host,
158- vault, suggested:{provider, type, identifier, priority, status,
159- last_confirmed, preferred_action, action_notes}}] }`.
160- Pure, deterministic given ` op ` output → trivially testable with a stub.
173+ 1 . ** ` scripts/import-1password.sh ` ** — the pull (BUILT). Checks ` op ` exists
174+ and is signed in, runs ` op vault list --format=json ` for the picker and
175+ ` op item list --format=json ` for the items, and pipes the result through
176+ the mapper to stdout. Nothing touches disk: the pull is held in memory,
177+ and a failed pull prints nothing rather than a truncated list. Exit
178+ codes carry the failure mode: 3 = op absent, 4 = no signed-in account,
179+ 5 = the pull was refused, 6 = python3 missing.
180+ ** ` scripts/map-1password.py ` ** — the mapping (BUILT), split out because
181+ it is pure: JSON in, JSON out, no ` op ` , no network, so the whole mapping
182+ contract is testable from a fixture. It reads exactly five fields per
183+ item (title, category, urls, vault, updated_at) and drops the rest,
184+ including ` additional_information ` .
185+ The ** candidate JSON** it emits:
186+ `{source, pulled, vaults:[ {name, items}] , counts:{items, candidates,
187+ shown_by_default, hidden, deduped, other_vaults}, candidates:[ {title,
188+ category, url_host, vault, updated, rank, default_include,
189+ possible_duplicate_of?, title_collisions?, suggested:{provider, type,
190+ identifier, priority, ownership, status, last_confirmed,
191+ preferred_action, action_notes, access_pointer}}] }`.
192+ ` rank ` is the money-first ordering (0-3 accounts, 4-5 non-accounts) and
193+ ` default_include ` is the "show everything" toggle — non-accounts are
194+ emitted, not withheld, so the toggle needs no second pull.
161195 The manager-agnostic shape is deliberate: a future Bitwarden importer
162196 (` bw list items ` ) plugs in behind the same contract (parked, not built).
1631972 . ** ` web/edit-server.py ` ** — one new endpoint, ` GET /import/1password `
@@ -179,17 +213,22 @@ the tail skippable).
179213## 7. Build phases
180214
181215- ** P0 — empirical spike. DONE 2026-07-29, findings in §9.**
182- - ** P1 — pull + mapping.** ` import-1password.sh ` , candidate JSON contract,
183- stub-` op ` CI fixture, mapping + dedup unit tests. Drops
184- ` additional_information ` , adds the finance-heuristic ranker (§9).
216+ - ** P1 — pull + mapping. DONE 2026-07-29** , findings in §11.
217+ ` import-1password.sh ` + ` map-1password.py ` , candidate JSON contract,
218+ stub-` op ` fixture, 38 tests. Drops ` additional_information ` , adds the
219+ finance-heuristic ranker (§9). The §2.1 grep guard landed here rather
220+ than in P4: a boundary is worth more from the moment the code exists.
185221- ** P2 — editor integration.** Endpoint, vault picker, both modes, the
186222 wizard filter box (§9), seal-time honesty gate. Deterministic test via the
187223 existing edit-server harness with the stub ` op ` on PATH.
188224- ** P3 — review-time re-import.** Same button in review mode, dedup against
189225 the decrypted register, "new since last pull" framing.
190- - ** P4 — docs + guards.** README + site get-started ("have 1Password? two
191- minutes instead of twenty"), SECURITY.md boundary note (§2), AGENTS.md
192- line, CI grep test that ` --reveal ` /concealed-field reads appear nowhere.
226+ - ** P4 — docs.** README + site get-started ("have 1Password? two minutes
227+ instead of twenty"), SECURITY.md boundary note (§2), AGENTS.md line, and
228+ the 1Password 8 + CLI-integration prerequisite (§9.7). The CI grep guard
229+ it used to own shipped with P1. Docs must state the boundary in plain
230+ English ** without quoting the forbidden flags** — the guard is a literal
231+ grep, and it should stay dumb enough that nothing can talk it round.
193232
194233Rough size: comparable to the browser-seal build (EF-ISS-7) — a day of
195234sessions.
@@ -255,3 +294,36 @@ category as a hint; P2's wizard gains the filter box.
255294- Other password managers (Bitwarden etc.) — the candidate JSON contract is
256295 designed for them, but none are built now.
257296- Windows-native ` op ` flows (owner tooling is macOS/Linux/WSL, as today).
297+
298+ ## 11. P1 build findings (2026-07-29)
299+
300+ Built ` scripts/import-1password.sh ` + ` scripts/map-1password.py ` , a stub
301+ ` op ` (` tests/stub-op/op ` ) with fixtures, and 38 tests. Suite: 146 pass, 0
302+ fail. Verified twice against the real 563-item vault, not only fixtures —
303+ which is where three defects surfaced that fixtures never would have:
304+
305+ 1 . ** Substring keyword matching is too loose.** "Readwise" matched the
306+ finance hint "wise" and ranked as money. Fixed: hints match from a word
307+ boundary (so "hargreaveslansdown.com" still hits), and the short
308+ ambiguous ones (isa, ira, irs, tax, wise, visa, loan) must match whole
309+ words. Real-vault rank-1 count went 48 → 40.
310+ 2 . ** Non-accounts could outrank accounts.** A secure note called "Trader-7
311+ Coinbase API" scored a finance hit and sorted above real accounts while
312+ being hidden by default. Fixed: accounts occupy ranks 0-3, non-accounts
313+ 4-5 — so the "show everything" toggle appends to the list instead of
314+ reshuffling it.
315+ 3 . ** Title clashes are common, not rare: 178 of 563 items share a title**
316+ with another item (the same messy vault behind T-284). They would share
317+ a pointer, so re-import would hide the twin. Mapper now emits
318+ ` title_collisions ` ; P2's wizard must act on it (§5).
319+
320+ Also confirmed live: the not-signed-in and approval-timeout paths are real
321+ and exit cleanly (the vault session expired mid-session and the wrapper
322+ said so in plain English, printing nothing on stdout). Real-vault shape:
323+ 563 items → 540 shown by default, 23 hidden, 7 at rank 0, 40 at rank 1,
324+ 493 generic. That 493 is the tail the wizard's filter box (§9.6) exists
325+ for, and the strongest argument for P2 taking ergonomics seriously.
326+
327+ Deviations from the spec as written, both deliberate and both above:
328+ ` access_pointer ` carries the pointer instead of ` identifier ` (§4), and the
329+ grep guard shipped in P1 instead of P4 (§7).
0 commit comments