Take accept, secret-ask, issued and rotate into the vault (issue 675)

ADR 0349 clauses 8 to 10: a value given at the controller's terminal, typed
at a desk or issued by a provider's software enters the vault and opens
there alone, and rotation is the vault's. Each comes in two steps from the
controller, which reaches the desk and the provider: the vault makes a key
for the one call and opens what was sealed to it. An ask is recorded in the
vault's store before the prompt opens and bounded to one open per secret
and three an hour.

Also V1's review follow-ups: the tool layer passes an act through, a
rolled record is said, the export's fingerprint and vault_status are
tested, and an unproven by is kept beside the proven caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 19:10:03 +00:00
co-authored by Claude Opus 5.5
parent 1a2c917abf
commit 2abe1f95e8
3 changed files with 124 additions and 3 deletions
+105 -1
View File
@@ -39,6 +39,12 @@ type Record struct {
MadeAt time.Time `json:"made-at"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
// carrying a person's act (design 57's V2). Empty when they are the same.
ChangedVia string `json:"changed-via,omitempty"`
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
Origin string `json:"origin,omitempty"`
}
// OperatorCopy is a value sealed to the operator key named in Key.
@@ -58,6 +64,8 @@ type Store struct {
handedMu sync.Mutex
rolled int
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
asksMu sync.Mutex
}
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
@@ -160,6 +168,7 @@ func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
if err := s.roll(path); err != nil {
return false, err
}
s.rolled++
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
@@ -176,7 +185,6 @@ func (s *Store) roll(path string) error {
if err := os.Rename(path, path+".1"); err != nil {
return err
}
s.rolled++
return syncDir(s.dir)
}
@@ -334,3 +342,99 @@ func syncDir(dir string) error {
defer d.Close()
return d.Sync()
}
// The two lines an ask leaves in the record: when it opened, and how it ended.
const (
AskOpened = "opened"
AskEnded = "ended"
)
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
// Never a value.
type AskRecord struct {
ID string `json:"id"`
At time.Time `json:"at"`
Event string `json:"event"`
Secret string `json:"secret"`
Node string `json:"node,omitempty"`
Module string `json:"module,omitempty"`
Name string `json:"name,omitempty"`
Desk string `json:"desk,omitempty"`
AskedBy string `json:"asked_by,omitempty"`
Caller string `json:"caller,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
// hand-outs is, and it says whether it was.
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
raw, err := json.Marshal(r)
if err != nil {
return false, err
}
path := filepath.Join(s.dir, "asks.jsonl")
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
if err := s.roll(path); err != nil {
return false, err
}
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
func (s *Store) asks() ([]AskRecord, error) {
path := filepath.Join(s.dir, "asks.jsonl")
var out []AskRecord
for i := HandedKept; i >= 0; i-- {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
var r AskRecord
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
out = append(out, r)
}
}
}
return out, nil
}
// AsksSince is every line of the record of asks at or after since, oldest first.
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
all, err := s.asks()
if err != nil {
return nil, err
}
var out []AskRecord
for _, r := range all {
if !r.At.Before(since) {
out = append(out, r)
}
}
return out, nil
}
// Asks is the latest lines of the record of asks, newest first, at most limit.
func (s *Store) Asks(limit int) ([]AskRecord, error) {
s.asksMu.Lock()
defer s.asksMu.Unlock()
all, err := s.asks()
if err != nil {
return nil, err
}
out := []AskRecord{}
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, all[i])
}
return out, nil
}
+13 -1
View File
@@ -35,6 +35,9 @@ type Vault struct {
Log func(format string, args ...any)
mu sync.Mutex
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex
pending map[string]*callKey
}
// Changed is what one change to a secret was. Never its value.
@@ -65,6 +68,12 @@ func (v *Vault) log(format string, args ...any) {
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
return v.keep(name, value, by, "", OriginMade)
}
// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who
// by names (via).
func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
@@ -104,9 +113,12 @@ func (v *Vault) Put(name, value, by string) (Changed, error) {
return Changed{}, err
}
now := v.Now().UTC()
if via == by {
via = ""
}
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by}
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
}
+6 -1
View File
@@ -112,6 +112,7 @@
"secret_holders",
"secret_verify",
"secret_handed",
"secret_asks",
"secret_vault_export",
"provisioner_retirement",
"provisioner_retire_approve",
@@ -124,7 +125,11 @@
"name": "mesh-vault",
"scope": "mesh",
"serves": [
"get"
"get",
"accept",
"secret-ask",
"issued",
"rotate"
]
}
]