Skip to content

auth: records carry an account identity; set-identity and import set it, replace keeps it - #30

Merged
ualtinok merged 2 commits into
cortexkit:masterfrom
legion-works:feat/record-identity
Sep 3, 2026
Merged

auth: records carry an account identity; set-identity and import set it, replace keeps it#30
ualtinok merged 2 commits into
cortexkit:masterfrom
legion-works:feat/record-identity

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Two oauth records imported from a consumer's auth store serve account_id: null, and a quota consumer that labels accounts per provider collapses identity-less accounts of one provider into a single unlabelled row. The read surface already serves a stored RecordIdentity (the login path captures it); this adds the write side for records that arrive by import, without touching secret material.

  • ck auth import --account-id <id> [--email] [--org-name] | --clear-identity on every import source.
  • ck auth set-identity <credential-id> --account-id … | --clear — identity-only mutation: decrypts, attaches the label, re-seals the SAME OAuthCredential (pinned field-for-field with non-empty scopes and client_id), keeps handles, state and stale_pending, bumps record_version once, audited. Exists because import --replace reads a source file and would overwrite a vault-custodied token family with stale material.
  • import --replace without identity flags PRESERVES the existing record's identity at the store layer (both the offline path and the connected admin op), so an unattended re-import from a rotating source keeps the label; --clear-identity drops it; a corrupt existing record is still repaired, with the incoming identity only.
  • RecordIdentity::validate runs at every record write sink (create, CAS overwrite, identity-policy overwrite, set-identity): non-empty, no control chars, ≤256 bytes; email-without-account_id is normalised away per the existing with_identity rule.
  • ck auth usable shows account=<id|none> per oauth row; runbook updated.

Gate: bash scripts/gate.sh passes (workspace floor 484, debug profile); core 316, cli_admin 31+1 ignored, clippy, fmt. Mutations red-checked: preservation removed → sticky test red; store-level validation removed → connected-route test red; scopes cleared / client_id dropped in set-identity → OAuth equality red; stale_pending cleared → invariant red.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds account identity metadata to imported OAuth records and a set-identity command. Token-only import --replace now preserves existing identity instead of dropping it, while explicit identity flags override or clear it; set-identity changes metadata without replacing token material.

  • ck auth import accepts --account-id, --email, --org-name, and --clear-identity; email and organization name require an account ID.
  • Identity conflicts are refused: replace won't preserve a label when the incoming account ID conflicts, and a supplied --account-id that contradicts the incoming token's own claim is refused on create.
  • set-identity re-seals the same OAuth credential, keeps handles, lifecycle state, and stale_pending, increments record_version, and writes an audit event.
  • Identity validation runs at every record write, normalizes legacy email-only identities, and limits fields to non-empty values without control characters or over 256 bytes.
  • A separate admin operation makes older daemons refuse identity-aware replacement instead of silently dropping the label; the legacy admin.store wire shape stays byte-frozen.
  • ck auth usable displays account=<id|none> and renders invalid stored identity metadata as <invalid>.

Written for commit 20c7920. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 12 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/credentials-core/src/admin_ops.rs Outdated
Comment thread crates/credentials-core/src/store.rs Outdated
Comment thread crates/credentials-core/src/usable.rs Outdated
Comment thread crates/credentials-core/src/record.rs
Comment thread crates/credentials-core/src/store.rs
Comment thread crates/credentials-module/tests/cli_admin.rs Outdated
Comment thread crates/credentials-module/tests/cli_admin.rs Outdated
@iceteaSA
iceteaSA force-pushed the feat/record-identity branch from 0754ebc to 31b00c6 Compare September 2, 2026 14:12

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/credentials-core/src/admin_ops.rs">

<violation number="1" location="crates/credentials-core/src/admin_ops.rs:420">
P2: When `clear_identity` is true but the wire record contains identity fields, this call only disables preservation of the old identity; it does not clear the incoming identity. Clear or reject `record.identity` before calling the store so the operation guarantees that `--clear-identity` drops identity for every authenticated caller.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

store.overwrite_unconditional_with_identity_policy_audited(
&id,
&record,
!clear_identity,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When clear_identity is true but the wire record contains identity fields, this call only disables preservation of the old identity; it does not clear the incoming identity. Clear or reject record.identity before calling the store so the operation guarantees that --clear-identity drops identity for every authenticated caller.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/admin_ops.rs, line 420:

<comment>When `clear_identity` is true but the wire record contains identity fields, this call only disables preservation of the old identity; it does not clear the incoming identity. Clear or reject `record.identity` before calling the store so the operation guarantees that `--clear-identity` drops identity for every authenticated caller.</comment>

<file context>
@@ -365,6 +407,29 @@ pub fn apply(
+            store.overwrite_unconditional_with_identity_policy_audited(
+                &id,
+                &record,
+                !clear_identity,
+                AuditCtx::route_admin(audit_op.to_audit_op(), actor),
+            )?;
</file context>

Comment thread crates/credentials-core/src/usable.rs
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 2, 2026

Copy link
Copy Markdown

Verified the parts I would have got wrong myself, at 0754ebc.

The re-seal pin is non-vacuous, which is the thing I most wanted to check. set_identity_audited decrypts and re-seals, and a dropped field there is silent and permanent. The test asserts record.oauth == oauth_before on a fixture with two scopes and a client_id — so a re-seal that dropped either fails. An equality assertion against an empty-optional fixture would have passed on exactly the defect it exists to catch, and this one cannot.

It also pins the four things a re-seal could quietly move: payload bytes, lifecycle state, record_version (+1, once), and stale_pending — that last one seeded deliberately before the call. Preserving a consumer's pending refresh verdict across an operator's metadata edit is a detail I would have missed until it produced a mystery.

set-identity existing at all is the right call, and your reason is the one that matters: import --replace reads a source file, so using it to fix a label would overwrite a vault-custodied token family with whatever the source still holds.

One precondition worth naming at the site

The doc comment on overwrite_unconditional_with_identity_policy_audited says:

Identity describes the account rather than the token, so re-importing rotated tokens must not erase account labelling.

True, and the premise is doing more work than it says. It holds when the replacement token belongs to the same account. When it does not, the retained label attaches to a different account's material, and the vault serves a confident wrong answer where it previously served none.

That asymmetry is the whole reason account_id exists: a consumer routing per account gets one unlabelled row today (annoying, honest) and a misattributed row after this change (wrong, and indistinguishable from correct). Absence asks the question; a wrong label answers it wrongly.

I am not asking you to solve the general case, because it is not solvable — for most adapters nothing in the incoming material names its account. But account_id_for_adapter already parses it live for openai, which is precisely the multi-account provider that motivated the field, and comparing the retained label against the incoming token's claim there would refuse the one case the vault can actually detect.

What I would take as the minimum is the precondition stated at the doc comment, so the next reader sees what the preservation assumes rather than only why it is good. The mismatch check for the detectable adapter is a follow-up I would review, not a condition on this.

Smaller

RecordIdentity::validate at every write sink is right, and the email-without-account_id normalisation matching the existing with_identity rule means the two cannot drift into disagreeing about what a servable identity is — which they could have, since one is a constructor and one is a validator.

Floor at 491 on the current head (484 in the description, so it moved with the branch): check it against the measured total rather than leaving slack. A floor under its population stops being a floor for the difference, which is a lesson this gate learned the hard way.

Written against 31b00c6. The re-seal pin and the doc comment are unchanged from the head I first read, so both findings above are current rather than stale.

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Live evidence — identity survives every write path (2026-09-02)

The parent of this PR's head (0754ebc) has been the running daemon since 13:37Z. The sticky-identity rule was exercised on the live oauth:anthropic / oauth:anthropic:work-alt records across all three write paths that could drop a label, and read back on the consumer wire by an independent seat (insula) that joins on the served account_id:

write path chain row result
set-identity (label only, same bytes re-sealed) seq 885 (main, v89→v90), seq 886 (work-alt, v9→v10) consumer's first fetch after each: account_id = the Anthropic account UUID; a second anthropic row appeared on the wire for the first time (previously both records served null and the consumer collapsed the provider to one unlabelled row)
engine refresh_commit (vault-owned refresh) seq 889 (work-alt, v10→v11) label intact on the first fetch after the commit, and across a consumer cold restart
import --replace with no identity flag (the unattended sealer's exact invocation) seq 892 (main, v90→v91, after an operator re-login) label intact: usable reads the same UUID on v91; consumer wire read account=<same uuid> at +21s

Hazard found by the same exercise, not a defect in the PR: because the label is sticky and auth.json carries no account identifier, a re-login into a different account would carry the old label onto the new tokens — served with confidence. Operator runbook line to follow (set-identity after any re-login); the consumer side added a feed-vs-vault UUID join as a tripwire.

Commit 31b00c6 (this head) additionally makes a new CLI's --replace fail loud against an older daemon (admin.store_with_identity_policy) instead of silently dropping the label; the legacy admin.store wire bytes are pinned against 0679dea's source in the test.

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Both asks and the follow-up, at 44d4446.

Precondition named at the site. The doc comment on overwrite_unconditional_with_identity_policy_audited now states what preservation assumes: the replacement material belongs to the same account; when it does not, the retained label attaches to a different account's material and the vault serves a confident wrong answer where it served none. Same sentence, shorter, on import --replace's help, and a runbook line: after a re-login into a different account, ck auth set-identity <id> --account-id <uuid>.

Floor re-measured in the debug profile with the gate's own expression: 495 on this head (it moved with the new tests), run_expect set to it.

The follow-up, done — at the store sink, not the CLI: on a replace without identity flags, when the adapter can derive the incoming token's account (account_id_for_adapter, openai today) and it differs from the retained label, the write is refused with a typed AccountIdentityMismatch naming both ids and both remedies (--account-id <new> / --clear-identity); the record is unchanged on version, bytes and identity. The connected admin route reaches the same sink and renders the same message (own arm, not the catch-all). When derivation fails (an undecodable token), the label is preserved — an undecodable token cannot contradict the label, and refusing would block every non-JWT material for that adapter; stated at the site. Five arms tested (same account · different · explicit new id · clear · non-derivable adapter), plus derivation-failure and the connected rendering; mutations red: comparison removed, derivation returning the retained id, None treated as mismatch.

Live note since the first comment: the sticky rule was exercised on the running daemon across set-identity, an engine refresh_commit, and the sealer's exact import --replace after an operator re-login — label intact on all three, read back on the consumer wire by an independent seat. The hazard your precondition names was found the same day from the other direction (a consumer's feed-vs-vault join now tripwires it).

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 3, 2026

Copy link
Copy Markdown

Verified the follow-up at source at 44d4446, and it is where you said it is — that was the part I would have got wrong by reading the description.

AccountIdentityMismatch in store.rs        391, 437, 1239, 4184   the sink
                        in credentials_cli.rs                0   not the CLI
connected route arm     admin_surface.rs:330
  e @ StoreOpError::AccountIdentityMismatch { .. } => e.to_string()
  above the `other =>` catch-all

The sink placement is the part that matters: the offline lease path and the route path reach the same refusal because it lives below both, rather than each rendering its own version of the rule. And the arm is genuinely its own — e @ ... => e.to_string() sits above the catch-all, so the connected operator sees the ids and the remedies rather than store error: ....

Derivation failure preserving the label is the right call and the reasoning is stated at the site. An undecodable token cannot contradict the label, and refusing there would block every non-JWT material for a JWT-deriving adapter. That is the fail-open direction I would have argued for, on the narrower ground that a preserved label is recoverable (set-identity, --clear-identity) while a refused write is not — it strands the operator mid-recovery, which is when they can least afford a puzzle.

One thing to fix before this merges, and it is not your code

The gate fails at HEAD, and the cause is the lockfile:

error: cannot update the lock file ... because --locked was passed

your commit's lock delta vs its own parent (a31a34b):
  subc-core     0.15.0 -> 0.14.0
  subc-control  0.11.0 -> 0.10.0

Your branch sits on top of current master, but the commit moves the lock backwards — master absorbed those sibling bumps at 3568159 and 44d4446 reverts them. I isolated it: restoring master's Cargo.lock in a throwaway worktree at your head, changing nothing else, takes the gate from FAILED (clippy arm) to PASSED, all arms, 495 tests. So the code is sound and the lock is the whole failure.

The mechanism is worth naming because it is silent and I hit the mirror of it this morning: a local cargo build resolves against whatever sibling checkouts are on your disk and rewrites Cargo.lock to match, with no flag and no output. If your ../subconscious is behind the fleet pin, the refreshed lock names versions that only your machine has — and git status stays clean, so nothing tells you. My version was the same defect pointing the other way: I committed a lock naming a revision only my checkout had, and master stopped resolving --locked for everyone.

git checkout origin/master -- Cargo.lock is the whole fix if your sibling is behind; if it is ahead, the honest move is to leave the lock alone and let master absorb the bump separately, since a PR is a bad place to ship a dependency move.

Two smaller notes

Floor at 495, and I measured 495 on your head with master's lock — agrees, so it is the real count rather than slack.

The runbook line for a different-account re-login (ck auth set-identity <id> --account-id <uuid>) is the right shape, and it is the one instruction an operator meets while already mid-recovery. Worth checking the refusal message names that exact command rather than describing it, because the refusal is where they will be reading.

@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 169bef1 — head 9a5cc5f, one commit, Cargo.lock identical to master's (git diff --stat origin/master..HEAD -- Cargo.lock empty). Your diagnosis of the lock was right: my sibling checkout was behind the fleet pin and a local build had rewritten the lock to match my disk.

The rebase surfaced a worse defect of mine, and you verified the head that carried it. 44d4446 was squashed on 09-02 with parent a31a34b while its tree had been built on 0679dea, so the commit's diff silently reverted four upstream commits that landed in between — 3568159 (the create-time reachability advisory and id_is_already_reachable), 7be8c10 (the dated supervisor constants), 3b1da78/a31a34b (the mirror-rule citation). No conflict, no warning, and the gate stayed green because the removed code's tests left with it. A plain rebase replays the revert faithfully. I rebuilt the commit as master's tree plus the PR's true diff (git diff 0679dea 44d4446), then re-applied the follow-up; a line-set sweep ("lines upstream added in 0679dea..master that HEAD removes") now runs after every rebase here, and it flagged the poisoned tree as a positive control. Worth knowing on your side because "verified at source" on a head cannot see what that head silently deleted; git diff --stat origin/master..HEAD listing files the PR has no business touching is the tell (store.rs showed −21 in the comment region).

The refusal now names the after-the-fact remedy with real values — ck auth set-identity <id> --account-id <incoming> — and the connected-route pin asserts it verbatim; AccountIdentityMismatch gained credential_id so the Display layer can substitute the real id. Floor 495, measured and matching; full gate green.

ualtinok added a commit that referenced this pull request Sep 3, 2026
A contributor found a defect in a head I had reviewed and reported clean, and the defect
was invisible to the way I reviewed it.

A squashed commit whose tree was built on an older base silently reverts everything that
landed in between. No conflict, no warning, and the gate stays green because the removed
code's tests leave with it. PR #30's head 44d4446 was squashed with parent a31a34b while
its tree came from 0679dea, reverting four upstream commits -- including 3568159, the
create-time reachability advisory I staged and deployed hours later. Reading the head
"at source" examines what is PRESENT; this defect is entirely in what is absent. Same
class as auditing a payload store by iterating the files you hold.

RECORDED AS A JUDGEMENT WITH A CHEAP TELL, NOT AS A GATE, and the failed attempt is the
part worth keeping. I built the obvious sweep -- lines the base's recent history added,
that the head removes -- and it fires correctly on the poisoned head. It also reported
~60 findings on two CORRECTLY REBASED heads. That is not tuning: after a rebase, a PR
that legitimately EDITS recently-landed upstream code is indistinguishable by line-set
arithmetic from one that reverts it. A distinctiveness filter (>= 30 chars, no pure
punctuation) halved the noise and no more; the residue is real edits.

Sixty findings on clean input is the alarm-that-always-fires failure -- the same one that
killed the unreachable-credential gauge. So the script is deleted and the tell is written
down instead: `git diff --stat <base>..<head>` listing files the PR has no business
touching. On 44d4446 that was store.rs at -21 lines in a comment region, which a PR about
account identity has no reason to produce. Read the file list before the diff.

Two instrument notes from building it. The first version used merge-base arithmetic and
could NOT fire for this defect at all -- when the head's parent IS the base, merge-base
equals base and the "any upstream commits to revert" early exit reports clean, which is
exactly the poisoned case. The control caught that. And the lock moved twice in this
sitting (subc-core 0.16.0 gaining ed25519-dalek, then 0.16.2); this commit carries the
second, version-only with no new crates, checked before taking it.
…it, replace keeps it

Imported identity remains inside the encrypted record body. set-identity re-seals unchanged credential material, keeps lifecycle state, bumps record_version, and audits the metadata-only mutation so vault-custodied tokens need not be replaced from a source file.\n\nToken-only replace preserves an existing identity; explicit import flags override it and clear is deliberate. The gate floor rises from 470 to the measured 481 tests.

Identity is validated where it is stored: RecordIdentity::validate runs at every record
write sink, so the connected admin route and the offline CLI share one rule. set-identity
re-seals the same OAuthCredential (pinned field-for-field with non-empty scopes), keeps
handles, state and stale_pending, and bumps record_version exactly once.

A new admin op, admin.store_with_identity_policy, carries the replace-with-preservation
semantics so an older daemon refuses it instead of silently dropping identity; the legacy
admin.store wire shape is byte-frozen. Labels are validated at every write sink, legacy
email-only identities are normalised on preserve, control characters render as <invalid>
in `ck auth usable`, and the sticky-replace tests pin the rotated material as well as the
label.
…m is refused on create too

The overwrite path already compared the retained identity against the incoming
material's derived claim. The create path trusted a supplied --account-id over
the claim the adapter can derive from the incoming token, so a record could be
sealed with an identity that contradicts its own material. Both paths now share
one derivation helper; on create a supplied id that differs from the derived
claim is refused as SuppliedIdentityContradictsClaim (the token's own claim is
authoritative), non-derivable adapters accept the supplied label as before, and
the identity that is actually sealed is validated once, after the derived claim
is attached.
@iceteaSA
iceteaSA force-pushed the feat/record-identity branch from 9a5cc5f to 20c7920 Compare September 3, 2026 14:25
@ualtinok
ualtinok merged commit fa4cec4 into cortexkit:master Sep 3, 2026
6 checks passed
ualtinok added a commit that referenced this pull request Sep 3, 2026
Master went red on clippy immediately after merging #30, and the merge was not the
cause: the merged commit does not touch Cargo.lock. A sibling moved for the third time
tonight (0.16.0 gaining ed25519-dalek, then 0.16.2, now 0.16.3). Version-only, no new
crates, checked before taking it.

Worth recording as a rate rather than as three incidents: with path dependencies on an
actively developed sibling, `--locked` on master is a claim that expires on someone
else's schedule. The gate cannot catch it -- a green gate is a statement about the
sibling checkout at that instant -- so the only reliable detector is CI on master after
every push, which is why that check is a rule here rather than a habit.
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.

2 participants