Problem
1. Manual-test output is cryptic
docs/manual-test-stage4.md walks testers through verifying that CLI sessions are stored in the real macOS Keychain. The doc defines an ak-keychain-meta helper that wraps security(1), and testers are asked to eyeball its output to confirm the entry exists. That output looks like this:
```
keychain: "/Users/hanwencheng/Library/Keychains/login.keychain-db"
version: 512
class: "genp"
attributes:
0x00000007 ="agentkeys"
0x00000008 =
"acct"="session"
"cdat"=0x32303236303431313033323535335A00 "20260411032553Z\000"
"crtr"="aapl"
"mdat"=0x32303236303431313033323535335A00 "20260411032553Z\000"
"svce"="agentkeys"
"type"=
...
```
Those 4-char field names (`svce`, `acct`, `cdat`, `mdat`, `crtr`, `desc`, `gena`, `icmt`, `invi`, `nega`, `prot`, `scrp`, `cusi`, `type`) are 32-bit integer constants from `<Security/SecKeychainItem.h>` that happen to be valid ASCII when viewed as 4 bytes:
```c
enum {
kSecServiceItemAttr = 'svce', // = 0x73766365
kSecAccountItemAttr = 'acct',
kSecCreationDateItemAttr = 'cdat',
kSecModDateItemAttr = 'mdat',
kSecCreatorItemAttr = 'crtr',
// ...
};
```
This is Apple's `FourCharCode` / `OSType` convention, inherited from Classic Mac OS 1984 (same system used for HFS file type codes, AppleEvent classes, QuickTime FourCCs, etc.). It's frozen binary ABI — Apple cannot rename these without breaking every piece of software that has ever touched the keychain. The modern `SecItem*` CFDictionary API layers symbolic CFString constants like `kSecAttrService` on top, but the wire bytes underneath are still `'svce'`, and `security(1)` surfaces those raw.
Impact: every human running the manual tests has to memorize or Google what each code means to verify a test passed. That's friction in a document whose job is to give fast, unambiguous pass/fail signals.
2. No guidance on where translation should live
The user's follow-up question was: "does this also apply to a future web UI and the backend data store?"
The honest answer is that the literal `sed` fix for problem 1 does not apply to those layers — the web UI won't shell out to `security(1)`, and the backend's Rust types in `crates/agentkeys-types/src/lib.rs` (`AuditEvent`, `Session`, `AuthRequest`, etc.) already use self-describing field names. But the underlying design principle does apply: compact/legacy wire formats are for machines; human-readable labels are for humans; translate at the layer closest to the human.
This guidance currently has no home in the repo. Future contributors adding surfaces beyond the CLI (speculative mobile app, web dashboard, log viewer) need to know: build your own presentation-layer formatter. Do NOT ask the backend to add display strings to its JSON responses. The backend emits neutral data; each client decides how to render it.
Solution
Two doc-only deliverables. Zero Rust source code changes.
Deliverable 1: Replace `ak-keychain-meta` in `docs/manual-test-stage4.md`
Swap the one-line helper for a `sed`-based pretty-printer that rewrites just the field labels, leaving the rest of the `security(1)` output structure intact. Keep the raw version as an escape hatch.
```bash
Raw Apple output — escape hatch for troubleshooting
ak-keychain-meta-raw() {
security find-generic-password -s agentkeys -a session 2>/dev/null \
|| echo "(no keychain entry)"
}
Human-readable metadata — the default used in every test
ak-keychain-meta() {
local raw
raw=$(security find-generic-password -s agentkeys -a session 2>/dev/null) || {
echo "(no keychain entry)"
return
}
printf '%s\n' "$raw" | sed -E \
-e 's/"svce"/service/' \
-e 's/"acct"/account/' \
-e 's/"cdat"/created/' \
-e 's/"mdat"/modified/' \
-e 's/"crtr"/creator/' \
-e 's/"desc"/description/' \
-e 's/"icmt"/comment/' \
-e 's/"gena"/generic_data/' \
-e 's/"invi"/invisible/' \
-e 's/"nega"/negative_flag/' \
-e 's/"prot"/protocol/' \
-e 's/"scrp"/script_code/' \
-e 's/"cusi"/custom_icon/' \
-e 's/"type"/type/' \
-e 's/0x00000007 /label /' \
-e 's/0x00000008 /alias /' \
-e 's#0x[0-9a-fA-F]+ "([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z[^\"]"#\1-\2-\3 \4:\5:\6 UTC#'
}
```
Translation table (source: `<Security/SecKeychainItem.h>`):
| 4-char |
Apple constant |
Human label |
| `svce` |
`kSecServiceItemAttr` |
`service` |
| `acct` |
`kSecAccountItemAttr` |
`account` |
| `cdat` |
`kSecCreationDateItemAttr` |
`created` |
| `mdat` |
`kSecModDateItemAttr` |
`modified` |
| `crtr` |
`kSecCreatorItemAttr` |
`creator` |
| `type` |
`kSecTypeItemAttr` |
`type` |
| `desc` |
`kSecDescriptionItemAttr` |
`description` |
| `icmt` |
`kSecCommentItemAttr` |
`comment` |
| `gena` |
`kSecGenericItemAttr` |
`generic_data` |
| `invi` |
`kSecInvisibleItemAttr` |
`invisible` |
| `nega` |
`kSecNegativeItemAttr` |
`negative_flag` |
| `prot` |
`kSecProtocolItemAttr` |
`protocol` |
| `scrp` |
`kSecScriptCodeItemAttr` |
`script_code` |
| `cusi` |
`kSecCustomIconItemAttr` |
`custom_icon` |
| `0x00000007` |
`kSecLabelItemAttr` |
`label` |
| `0x00000008` |
`kSecAliasItemAttr` |
`alias` |
After the swap, Test 0's verification step prints (same structure, only labels + timestamps change):
```
keychain: "/Users/hanwencheng/Library/Keychains/login.keychain-db"
version: 512
class: "genp"
attributes:
label ="agentkeys"
alias =
account="session"
created=2026-04-11 03:25:53 UTC
creator="aapl"
modified=2026-04-11 03:25:53 UTC
service="agentkeys"
type=
...
```
Why sed (not awk): an earlier sketch used awk with `match()` + a timestamp-formatter function, but it had two portability bugs — (a) `"[0-9]+Z\\000"` can't match because awk regex doesn't interpret `\000` as a literal NUL, and (b) POSIX awk lacks `{14}` bounded quantifiers. Plain `sed -E` on macOS (BSD sed) supports bounded quantifiers and is the right tool for a pure label-rewrite job. The approach is deliberately less ambitious — we don't rebuild the output, we just patch the labels — which makes it robust against Apple tweaking `security(1)`'s exact indentation or column widths.
Deliverable 2: New design note at `docs/field-name-translation.md`
A cross-layer design note (~200 lines) capturing the general principle and mapping it onto AgentKeys's real 8-stage plan. Sections:
- The immediate problem — the keychain 4-char codes (same context as above).
- The general principle — compact wire formats are for machines; human-readable labels are for humans; translate at the layer closest to the human. Three corollaries:
- Don't invent cryptic field names at layers you control.
- Translation belongs at the client, not the server.
- Always keep an escape hatch to the raw form (`ak-keychain-meta-raw`, `?format=raw` query param, etc.).
- How this maps to AgentKeys — the real 8-stage plan (0–7) has no mobile or web UI surface. Every human-visible surface in stages 0–7 is either terminal output from `agentkeys-cli` or prose in docs/README. Speculative future surfaces (mobile master app, web dashboard) are explicitly flagged as "not in the current plan" and given their own guidance: build your own formatter, don't push translation into the backend.
- Translation tables — the full 4-char → English mapping (Layer 1, from `ak-keychain-meta`), plus a Layer 2 example showing that the real `AuditEvent` struct in `crates/agentkeys-types/src/lib.rs` already has self-describing fields (`owner`, `agent`, `service`, `action`, `result`, `timestamp`) and therefore needs no backend-side translation — just client-side formatters.
- When to update this doc — whenever a new surface is added, the backend schema changes, or Apple changes `security(1)`'s output format.
The full embedded content lives in the internal plan file at `/Users/hanwencheng/.claude/plans/lazy-hatching-map.md` and will be copied verbatim to `docs/field-name-translation.md` when this issue is implemented.
Out of scope (explicitly not doing)
- Not switching `keyring-rs` away from the legacy `SecKeychainItem` API. The modern `SecItem*` CFDictionary API would expose `kSecAttrService` directly, but that's a Rust dependency change far beyond a docs-only improvement.
- Not writing a Rust binary for the pretty-printer. Shell + sed is enough; a Rust binary would need building/shipping/installing before manual tests can run, which defeats the "copy-paste into a terminal" UX.
- Not rewriting any of the 11 existing manual tests in `docs/manual-test-stage4.md`. The `ak-keychain-meta` function is swapped transparently — every test body continues to call it and just prints nicer output.
- Not pre-building any web/mobile formatter module. Those surfaces don't exist in the current plan.
- Not changing any backend types in `crates/agentkeys-types/src/lib.rs` or the `CredentialBackend` trait. The design note codifies why the existing schema is correct.
Verification plan
Deliverable 1
- Static check — preview the helper code block in `docs/manual-test-stage4.md`, confirm no mangled quotes/backslashes.
- End-to-end run:
```bash
cd ~/Projects/agentkeys
cargo run -p agentkeys-mock-server -- --port 8090 &
ak-keychain-wipe
cargo run -p agentkeys-cli -- --backend http://localhost:8090 init --mock-token pretty-print-test
ak-keychain-meta # expected: service/account/created/modified labels
ak-keychain-meta-raw # expected: unchanged Apple format
```
- Edge cases: empty keychain prints `(no keychain entry)`; wipe + re-init shows new timestamps; runs under `LC_ALL=C` and default locale produce identical output.
Deliverable 2
- Static check — preview `docs/field-name-translation.md` in a markdown renderer, verify the table renders and cross-references (`docs/manual-test-stage4.md`, `docs/spec/plans/development-stages.md`, `crates/agentkeys-types/src/lib.rs`) all resolve.
- No runtime test — doc is pure prose.
Global
- No `cargo test` or `harness/stage-4-done.sh` run needed — zero source changes.
Files touched
| Path |
Change |
| `docs/manual-test-stage4.md` |
Replace `ak-keychain-meta` helper, add `ak-keychain-meta-raw`, add short explanatory paragraph + troubleshooting-table row |
| `docs/field-name-translation.md` |
New file — cross-layer design note |
Filed from a planning session. Happy to pick this up once approved, or hand off to whoever is working the stage-4 docs track.
Problem
1. Manual-test output is cryptic
docs/manual-test-stage4.mdwalks testers through verifying that CLI sessions are stored in the real macOS Keychain. The doc defines anak-keychain-metahelper that wrapssecurity(1), and testers are asked to eyeball its output to confirm the entry exists. That output looks like this:```
keychain: "/Users/hanwencheng/Library/Keychains/login.keychain-db"
version: 512
class: "genp"
attributes:
0x00000007 ="agentkeys"
0x00000008 =
"acct"="session"
"cdat"=0x32303236303431313033323535335A00 "20260411032553Z\000"
"crtr"="aapl"
"mdat"=0x32303236303431313033323535335A00 "20260411032553Z\000"
"svce"="agentkeys"
"type"=
...
```
Those 4-char field names (`svce`, `acct`, `cdat`, `mdat`, `crtr`, `desc`, `gena`, `icmt`, `invi`, `nega`, `prot`, `scrp`, `cusi`, `type`) are 32-bit integer constants from `<Security/SecKeychainItem.h>` that happen to be valid ASCII when viewed as 4 bytes:
```c
enum {
kSecServiceItemAttr = 'svce', // = 0x73766365
kSecAccountItemAttr = 'acct',
kSecCreationDateItemAttr = 'cdat',
kSecModDateItemAttr = 'mdat',
kSecCreatorItemAttr = 'crtr',
// ...
};
```
This is Apple's `FourCharCode` / `OSType` convention, inherited from Classic Mac OS 1984 (same system used for HFS file type codes, AppleEvent classes, QuickTime FourCCs, etc.). It's frozen binary ABI — Apple cannot rename these without breaking every piece of software that has ever touched the keychain. The modern `SecItem*` CFDictionary API layers symbolic CFString constants like `kSecAttrService` on top, but the wire bytes underneath are still `'svce'`, and `security(1)` surfaces those raw.
Impact: every human running the manual tests has to memorize or Google what each code means to verify a test passed. That's friction in a document whose job is to give fast, unambiguous pass/fail signals.
2. No guidance on where translation should live
The user's follow-up question was: "does this also apply to a future web UI and the backend data store?"
The honest answer is that the literal `sed` fix for problem 1 does not apply to those layers — the web UI won't shell out to `security(1)`, and the backend's Rust types in `crates/agentkeys-types/src/lib.rs` (`AuditEvent`, `Session`, `AuthRequest`, etc.) already use self-describing field names. But the underlying design principle does apply: compact/legacy wire formats are for machines; human-readable labels are for humans; translate at the layer closest to the human.
This guidance currently has no home in the repo. Future contributors adding surfaces beyond the CLI (speculative mobile app, web dashboard, log viewer) need to know: build your own presentation-layer formatter. Do NOT ask the backend to add display strings to its JSON responses. The backend emits neutral data; each client decides how to render it.
Solution
Two doc-only deliverables. Zero Rust source code changes.
Deliverable 1: Replace `ak-keychain-meta` in `docs/manual-test-stage4.md`
Swap the one-line helper for a `sed`-based pretty-printer that rewrites just the field labels, leaving the rest of the `security(1)` output structure intact. Keep the raw version as an escape hatch.
```bash
Raw Apple output — escape hatch for troubleshooting
ak-keychain-meta-raw() {
security find-generic-password -s agentkeys -a session 2>/dev/null \
|| echo "(no keychain entry)"
}
Human-readable metadata — the default used in every test
ak-keychain-meta() {
local raw
raw=$(security find-generic-password -s agentkeys -a session 2>/dev/null) || {
echo "(no keychain entry)"
return
}
printf '%s\n' "$raw" | sed -E \
-e 's/"svce"/service/' \
-e 's/"acct"/account/' \
-e 's/"cdat"/created/' \
-e 's/"mdat"/modified/' \
-e 's/"crtr"/creator/' \
-e 's/"desc"/description/' \
-e 's/"icmt"/comment/' \
-e 's/"gena"/generic_data/' \
-e 's/"invi"/invisible/' \
-e 's/"nega"/negative_flag/' \
-e 's/"prot"/protocol/' \
-e 's/"scrp"/script_code/' \
-e 's/"cusi"/custom_icon/' \
-e 's/"type"/type/' \
-e 's/0x00000007 /label /' \
-e 's/0x00000008 /alias /' \
-e 's#0x[0-9a-fA-F]+ "([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z[^\"]"#\1-\2-\3 \4:\5:\6 UTC#'
}
```
Translation table (source: `<Security/SecKeychainItem.h>`):
After the swap, Test 0's verification step prints (same structure, only labels + timestamps change):
```
keychain: "/Users/hanwencheng/Library/Keychains/login.keychain-db"
version: 512
class: "genp"
attributes:
label ="agentkeys"
alias =
account="session"
created=2026-04-11 03:25:53 UTC
creator="aapl"
modified=2026-04-11 03:25:53 UTC
service="agentkeys"
type=
...
```
Why sed (not awk): an earlier sketch used awk with `match()` + a timestamp-formatter function, but it had two portability bugs — (a) `"[0-9]+Z\\000"` can't match because awk regex doesn't interpret `\000` as a literal NUL, and (b) POSIX awk lacks `{14}` bounded quantifiers. Plain `sed -E` on macOS (BSD sed) supports bounded quantifiers and is the right tool for a pure label-rewrite job. The approach is deliberately less ambitious — we don't rebuild the output, we just patch the labels — which makes it robust against Apple tweaking `security(1)`'s exact indentation or column widths.
Deliverable 2: New design note at `docs/field-name-translation.md`
A cross-layer design note (~200 lines) capturing the general principle and mapping it onto AgentKeys's real 8-stage plan. Sections:
The full embedded content lives in the internal plan file at `/Users/hanwencheng/.claude/plans/lazy-hatching-map.md` and will be copied verbatim to `docs/field-name-translation.md` when this issue is implemented.
Out of scope (explicitly not doing)
Verification plan
Deliverable 1
```bash
cd ~/Projects/agentkeys
cargo run -p agentkeys-mock-server -- --port 8090 &
ak-keychain-wipe
cargo run -p agentkeys-cli -- --backend http://localhost:8090 init --mock-token pretty-print-test
ak-keychain-meta # expected: service/account/created/modified labels
ak-keychain-meta-raw # expected: unchanged Apple format
```
Deliverable 2
Global
Files touched
Filed from a planning session. Happy to pick this up once approved, or hand off to whoever is working the stage-4 docs track.