Skip to content

feat(credstore): migration helpers (stderr line, _migration JSON, conflict error)#14

Merged
rianjs merged 3 commits into
mainfrom
feat/INT-435-migration-helpers
May 16, 2026
Merged

feat(credstore): migration helpers (stderr line, _migration JSON, conflict error)#14
rianjs merged 3 commits into
mainfrom
feat/INT-435-migration-helpers

Conversation

@rianjs
Copy link
Copy Markdown
Contributor

@rianjs rianjs commented May 16, 2026

Implements the package-level migration helpers from the Secret-Handling Standard §2.1 (line 412) + §1.8. PR7 — the final shared-library unit; Phase B (per-CLI migration) begins after this.

Surface

Symbol Behavior
EmitMigrationStderr(field, ref string) one-time stderr signal migrated <field> to keyring at <ref>; this is a one-time operation (exact §1.8 signature; unexported formatMigrationLine is the tested seam)
MigrationJSONEntry(field, from, to string) MigrationChange the §2.1-named per-change constructor
MigrationBlock / NewMigrationBlock(...) the _migration value (for CLIs embedding it behind their own json:"_migration" field); Version=1, non-nil Changes
MigrationObject / NewMigrationObject(...) standalone object → byte-for-byte {"_migration":{"version":1,"changes":[...]}}
MigrationConflictError(cli, field, legacyLocation, ref string) error + ErrMigrationConflict §1.8 line 254 conflict: names both locations, states they differ, offers the three remediation options
MigrationSchemaVersion / MigrationFieldName pinned schema constants

Design notes

  • Scope boundary. credstore owns formats + the conflict error only. Migration mechanics (read legacy config, move to keyring, rewrite file) and conflict detection (value comparison) stay per-CLI in Phase B. This package never sees a secret value.
  • Leak-proof by construction. MigrationConflictError takes no value argument, so it structurally cannot print either value, masked or unmasked (§1.8 line 254 / §1.12) — which is why, unlike PR6's redaction path, no safeErrorText-style scrubber is needed here.
  • ErrMigrationConflict reuses the PR6 ErrSecretLeaked/leakError sentinel pattern (Is(target) bool) so callers get a stable errors.Is signal independent of message text.
  • Exact JSON shape. Struct field order fixes key order; NewMigrationObject() with no changes marshals "changes":[] (never null). Tested byte-for-byte against the §1.8 example.
  • One new file credstore/migration.go (+ test). No changes to existing files, no new deps (all stdlib).

Tests

migration_test.go: exact stderr line + os.Pipe capture proving stderr-not-stdout, byte-for-byte §1.8 JSON (populated / empty-[] / multi-change / version pin), unmarshal round-trip, MigrationConflictError (errors.Is sentinel, both locations, three options, structural leak-proofness).

Architect-reviewed: converged 0/0/0/0 (Major — missing top-level _migration wrapper — and Minor — unexported the formatter — resolved in iteration 2).

[INT-435]
Closes #13

…flict error)

Add the migration surface from the Secret-Handling Standard §2.1
(line 412) + §1.8 — the final shared-library unit before per-CLI
migration:

- EmitMigrationStderr(field, ref) — the one-time human signal
  "migrated <field> to keyring at <ref>; this is a one-time
  operation"; unexported formatMigrationLine seam
- MigrationJSONEntry / MigrationBlock / MigrationObject (+ New*) —
  marshal byte-for-byte to the §1.8 shape
  {"_migration":{"version":1,"changes":[...]}}, empty -> "changes":[]
  not null; from/to are opaque locations, never the value
- MigrationConflictError + ErrMigrationConflict sentinel — §1.8
  line 254 conflict: names both locations, states they differ,
  offers the three remediation options; leak-proof by construction
  (no value argument)

credstore owns formats + the conflict error only; migration
mechanics and conflict detection remain per-CLI. No new dependencies.

[INT-435]
Closes #13
@rianjs
Copy link
Copy Markdown
Contributor Author

rianjs commented May 16, 2026

Codex architect PR review (converged, first pass)

No findings.

Verified against the agreed architecture:

  • _migration marshals byte-for-byte with stable key order, including changes:[] for empty migrations.
  • EmitMigrationStderr(field, ref) keeps the standard public signature, with only an unexported formatting seam.
  • MigrationConflictError has no value-bearing parameters, names both locations, states the conflict, offers the three remediation paths, and matches ErrMigrationConflict via errors.Is.
  • Scope stays clean: one implementation file plus tests, no backend/store changes, no Phase-B migration mechanics, no new deps.

Residual risk is caller discipline: field, from, to, legacyLocation, and ref must remain descriptors, not secret values. That matches the intended package boundary.

@rianjs
Copy link
Copy Markdown
Contributor Author

rianjs commented May 16, 2026

TDD / test-coverage assessment (independent)

go test ./credstore/ -race -count=1 — all 185 tests pass, 0 failures, 0 races.


What the tests cover well

  • Stderr text + channel: TestEmitMigrationStderr captures both pipes, asserts exact byte string including trailing \n, and asserts stdout is empty. Strong.
  • No trailing newline in formatMigrationLine: explicit negative check in TestFormatMigrationLine. Strong.
  • Byte-for-byte JSON (single change): TestMigrationJSONShapeByteForByte uses exact string equality, not substring. Strong.
  • Empty-changes [] not null: covered in the same test with exact string {"_migration":{"version":1,"changes":[]}}. Strong.
  • Schema version + field name constants: drift-guarded in the same test. Strong.
  • errors.Is sentinel: TestMigrationConflictError confirms errors.Is(err, ErrMigrationConflict). Strong.
  • All three remediation options: substring-checked (config clear, manually delete, --overwrite). Acceptable for prose.
  • Both locations named: cli, fld, loc, ref all substring-checked. Acceptable.
  • Round-trip unmarshal: TestMigrationObjectRoundTrip reconstructs and field-compares. Strong.

Gaps and weaknesses

High

  1. Multi-change ordering: substring, not byte-for-byte (migration_test.go:73–76). The multi-change ordering assertion uses strings.Contains + strings.Index comparison rather than an exact expected JSON string. This cannot catch regressions in the from/to fields of each entry, extra or missing commas, or whitespace. The single-change case has an exact assertion — the same rigour should apply here.

  2. NewMigrationBlock called directly: not tested. NewMigrationBlock is public API and is the inner building block. The test suite exercises it only indirectly via NewMigrationObject. There are no tests calling NewMigrationBlock(...) directly and marshalling the result, which means the standalone embedded-struct use case (a CLI that tags its own field json:"_migration" with a MigrationBlock value) has no coverage. The changes == nil guard (migration.go:82) is exercised only through NewMigrationObject(), not directly.

  3. MigrationBlock used as a standalone JSON field: not tested. The type comment explicitly describes this use case ("A CLI that embeds the signal as a field of its own response type tags that field json:\"_migration\" with this as the value"). No test marshals a struct that embeds MigrationBlock as a field to confirm the expected wire shape in that embedding scenario.

Medium

  1. Leak-proof assertion is a no-op (migration_test.go:127–129). The "sanity-check" for secret leakage only looks for "BEGIN" and "xoxb-" — two strings that were never passed as arguments and could not possibly appear. This assertion can never fail given the inputs used in the test; it gives false confidence. A genuinely useful structural assertion would be: confirm the error message contains exactly the four identifier strings passed in and nothing else that resembles a secret. At minimum, the test should assert that none of the identifier strings (which in a real call would be replaced with values that look like secrets) appear verbatim — but since the test passes safe strings, it cannot verify leak-proofness at all.

  2. Conflict error message: not exact. TestMigrationConflictError uses all-substring assertions for the error text. For a spec-mandated format (§1.8 line 254), an exact golden-string assertion (or at least an exact prefix/structure check) would catch unintended message rewording that could break downstream parsers or monitoring rules.

  3. MigrationJSONEntry is not tested independently. It is only used as an argument to other constructors. A single direct test confirming the returned MigrationChange struct field values would be minimal but worth having, especially since the field names are the wire contract.

Low

  1. No adversarial / boundary inputs for formatMigrationLine / EmitMigrationStderr: empty string field or ref, field/ref containing JSON-special characters (", \, <, >), non-ASCII / unicode. These flow into the JSON values unchanged in MigrationJSONEntry; the JSON encoder handles them correctly, but that path has no dedicated test.

  2. No adversarial inputs for MigrationConflictError: empty cli, field, legacyLocation, or ref; values containing format verbs (%s, %d) that could expose a fmt.Sprintf injection if the implementation ever changes; very long strings. The current implementation is safe, but the absence of these cases means a future refactor could silently regress.

  3. Pipe-capture approach in TestEmitMigrationStderr is fragile: replacing os.Stderr is not goroutine-safe and can interfere with other tests running in parallel (the test does not call t.Parallel()). This is not a bug with -count=1 and no parallelism, but worth noting for future test-suite evolution. The t.Cleanup restore is correct.


Summary table

Requirement Coverage Assertion strength
Stderr exact text + newline Yes Exact
Stderr not stdout Yes Exact
Single-change JSON byte-for-byte Yes Exact
Empty changes [] not null Yes Exact
Multi-change ordering Partial Weak (substring/index)
NewMigrationBlock direct No
MigrationBlock as embedded field No
Round-trip unmarshal Yes Field-equal
Constants pinned Yes Exact
errors.Is sentinel Yes Exact
Both locations in conflict msg Yes Substring
All three remediation options Yes Substring
Leak-proof structural assertion No (no-op) False assurance
Adversarial inputs (empty, unicode, format verbs) No

TDD-ASSESSMENT: adequate-with-gaps

Independent TDD review (adequate-with-gaps), all addressed in tests
only (no production change):

- conflict error: replace the no-op "BEGIN/xoxb-" sanity check and
  substring probes with a single exact golden-message assertion —
  subsumes both-locations + three-options and proves leak-proofness
  (message fully determined by the four non-value identifiers)
- multi-change JSON: exact bytes instead of strings.Index ordering
- direct NewMigrationBlock coverage (version + changes==nil guard +
  "changes":[]) and the documented embedded-field mode
  (MigrationBlock behind a caller's json:"_migration" field)
- MigrationJSONEntry tested in isolation
- JSON-escaping adversarial case (quote/backslash/unicode in field
  and to-location) with round-trip
- formatMigrationLine fmt-verb-inertness (inputs are args, not the
  format string)

Declined (with rationale): the os.Stderr pipe-swap is not
goroutine-safe — acceptable, no test here uses t.Parallel().

[INT-435]
@rianjs
Copy link
Copy Markdown
Contributor Author

rianjs commented May 16, 2026

Response to TDD assessment

All findings addressed in tests only (production code was already correct; gaps were assertion strength / missing cases):

  • High — multi-change JSON now an exact byte-for-byte assertion (was strings.Index); added direct NewMigrationBlock coverage (version, changes==nil guard, changes:[]) and the documented embedded-field mode (MigrationBlock behind a caller's own json:"_migration" field, full byte-for-byte).
  • Medium — the no-op BEGIN/xoxb- "sanity check" is removed, replaced by a single exact golden-message assertion for MigrationConflictError. That one assertion subsumes both-locations + all-three-options and proves leak-proofness: the message is fully determined by the four non-value identifiers, so nothing else can appear. MigrationJSONEntry now tested in isolation.
  • Low — added a JSON-escaping adversarial case (quote / backslash / unicode in field and to, with round-trip) for the byte-for-byte contract, and a formatMigrationLine fmt-verb-inertness case (inputs are Sprintf args, not the format string). The os.Stderr pipe-swap goroutine-safety point is declined with rationale: no test here uses t.Parallel(), so the global swap is safe as written; revisit only if parallelism is introduced.

187 tests pass with -race -count=1, lint clean. Thanks for the review.

Copy link
Copy Markdown

@monit-reviewer monit-reviewer left a comment

Choose a reason for hiding this comment

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

Automated PR Review

Reviewed commit: 73adb5c

Summary

Reviewer Findings
harness-engineering:harness-architecture-reviewer 1
harness-engineering:harness-self-documenting-code-reviewer 2
harness-engineering:harness-architecture-reviewer (1 findings)

⚠️ Should Fix - credstore/migration_test.go:38

TestEmitMigrationStderr swaps os.Stderr and os.Stdout globally. The test passes with -race today (per PR discussion), but the pattern is fragile: any future t.Parallel() call or -parallel execution with another test that touches stderr creates a race window between the pipe swap and the deferred restore. The safer fix is to accept an io.Writer in EmitMigrationStderr (or an unexported testable wrapper) so tests can inject a buffer instead of mutating global file descriptors.

harness-engineering:harness-self-documenting-code-reviewer (2 findings)

⚠️ Should Fix - credstore/migration_test.go:36

os.Pipe() errors are silently swallowed with _. If pipe creation fails, the test continues with nil file handles and will panic or produce a misleading assertion failure instead of a clear test error. Both return values should be checked and the test should call t.Fatal on error.

💡 Suggestion - credstore/migration_test.go:44

io.ReadAll errors on the stderr pipe are discarded (gotErr, _ := io.ReadAll(er)). A read failure silently produces an empty byte slice, causing the subsequent assertion to fail with a confusing mismatch rather than surfacing the underlying I/O error. Check the error and call t.Fatal.

4 info-level observations excluded. Run with --verbose to include.

3 PR discussion threads considered.


Completed in 1m 43s | $0.42 | sonnet | daemon 0.2.116 | Glorfindel
Field Value
Model sonnet
Reviewers hybrid-synthesis, harness-engineering:harness-architecture-reviewer, harness-engineering:harness-enforcement-reviewer, harness-engineering:harness-knowledge-reviewer, harness-engineering:harness-self-documenting-code-reviewer, security:security-code-auditor
Engine claude · sonnet
Reviewed by pr-review-daemon · monit-pr-reviewer
Duration 1m 43s wall · 2m 47s compute (Reviewers: 27s · Synthesis: 1m 13s)
Cost $0.42
Tokens 97.8k in / 9.9k out
Turns 6

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost
hybrid-synthesis sonnet 27.4k 4.5k 13.6k 13.8k (1h) $0.13
harness-engineering:harness-architecture-reviewer sonnet 14.0k 781 2.1k 11.9k (1h) $0.06
harness-engineering:harness-enforcement-reviewer sonnet 14.0k 616 2.1k 11.9k (1h) $0.06
harness-engineering:harness-knowledge-reviewer sonnet 14.0k 651 2.1k 11.9k (1h) $0.06
harness-engineering:harness-self-documenting-code-reviewer sonnet 14.0k 780 2.1k 11.9k (1h) $0.06
security:security-code-auditor haiku 14.3k 2.6k 0 14.3k (1h) $0.04

Re-reviews only run when @monit-reviewer is re-requested as a reviewer — push as many commits as you need, then re-request when ready. PRs targeting branches other than main, master are skipped, even when @monit-reviewer is re-requested.

Comment thread credstore/migration_test.go Outdated
os.Stderr, os.Stdout = ew, ow
t.Cleanup(func() { os.Stderr, os.Stdout = origErr, origOut })

EmitMigrationStderr("api_token", "atlassian-cli/default")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium (harness-engineering:harness-architecture-reviewer): TestEmitMigrationStderr swaps os.Stderr and os.Stdout globally. The test passes with -race today (per PR discussion), but the pattern is fragile: any future t.Parallel() call or -parallel execution with another test that touches stderr creates a race window between the pipe swap and the deferred restore. The safer fix is to accept an io.Writer in EmitMigrationStderr (or an unexported testable wrapper) so tests can inject a buffer instead of mutating global file descriptors.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: replaced the global os.Stderr/os.Stdout swap with the package's pure/impure seam convention — added unexported emitMigration(w io.Writer, field, ref); EmitMigrationStderr(field, ref) keeps its exact standard signature and is now a one-line os.Stderr delegate. The test drives emitMigration with a bytes.Buffer — no global fd mutation, parallelism-safe, no pipe. Commit pushed.

Comment thread credstore/migration_test.go Outdated
er, ew, _ := os.Pipe()
or, ow, _ := os.Pipe()
os.Stderr, os.Stdout = ew, ow
t.Cleanup(func() { os.Stderr, os.Stdout = origErr, origOut })
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium (harness-engineering:harness-self-documenting-code-reviewer): os.Pipe() errors are silently swallowed with _. If pipe creation fails, the test continues with nil file handles and will panic or produce a misleading assertion failure instead of a clear test error. Both return values should be checked and the test should call t.Fatal on error.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved structurally: the os.Pipe call is gone entirely (buffer-injection seam), so there are no longer any swallowed pipe-creation errors to check.

Comment thread credstore/migration_test.go Outdated
_ = ow.Close()
gotErr, _ := io.ReadAll(er)
gotOut, _ := io.ReadAll(or)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low (harness-engineering:harness-self-documenting-code-reviewer): io.ReadAll errors on the stderr pipe are discarded (gotErr, _ := io.ReadAll(er)). A read failure silently produces an empty byte slice, causing the subsequent assertion to fail with a confusing mismatch rather than surfacing the underlying I/O error. Check the error and call t.Fatal.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved structurally: io.ReadAll is gone (the test now asserts directly on a bytes.Buffer), so there is no discarded read error.

Daemon REQUEST_CHANGES — TestEmitMigrationStderr swapped global
os.Stderr/os.Stdout (fragile under future t.Parallel) and swallowed
os.Pipe / io.ReadAll errors.

Fix via the package's own pure/impure seam convention: add unexported
emitMigration(w io.Writer, field, ref); EmitMigrationStderr keeps its
exact standard signature and is now a trivial os.Stderr delegate. The
test drives emitMigration with a bytes.Buffer — no global fd mutation,
no pipe, no swallowed I/O errors, parallelism-safe. errcheck: the
best-effort Fprintln return is explicitly discarded with rationale
(stdlib fmt.Println family parity; no actionable recovery, signature
fixed by the standard).

[INT-435]
@monit-reviewer
Copy link
Copy Markdown

PR review failed and the daemon has stopped retrying for this commit. I am removing my pending review request to stop the automated review cycle. Re-request @monit-reviewer to retry, or push a new commit and re-request.

@rianjs rianjs merged commit b753d5c into main May 16, 2026
4 checks passed
@rianjs rianjs deleted the feat/INT-435-migration-helpers branch May 16, 2026 18:27
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.

credstore: migration helpers (stderr line, _migration JSON, conflict error)

2 participants