1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
use crate::audit::AuditArgs;
use crate::overlay::Overlay;
use crate::CliResult;
use clap::{Args, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Args)]
pub struct AssignArgs {
#[command(subcommand)]
pub kind: AssignKind,
}
/// Flags shared by every `zynk assign <type>` subcommand, mirroring `decide::Common`:
/// where the file-first audit artifact lands (`--root`), how it projects (`--db` /
/// `--no-db`, ADR 028), and which session/when (`--session-id` / `--timestamp`), plus
/// the participant the overlay is asserted ABOUT (`--subject`). An overlay is
/// operator-authored by definition (ADR 036 C3), so the proof hardcodes
/// `source_agent=operator`; there is no `--asserter` flag (it would be inert).
#[derive(Debug, Args)]
pub struct Common {
#[arg(long, default_value = "outputs")]
pub root: PathBuf,
#[arg(long)]
pub db: Option<PathBuf>,
#[arg(long)]
pub no_db: bool,
#[arg(long)]
pub session_id: String,
#[arg(long)]
pub timestamp: Option<String>,
/// The participant this overlay asserts a fact about.
#[arg(long)]
pub subject: String,
}
/// The participant-overlay kinds (ADR 036): one subcommand per `Overlay` variant.
/// actor-kind binds `--kind`; role binds `--role-id` + `--role-label`; trait binds
/// `--trait` + an optional `--unset` (sets `trait_value=false`).
#[derive(Debug, Subcommand)]
pub enum AssignKind {
/// Assert a participant's `actor-kind` (`human` / `agent` / `external`).
ActorKind {
#[command(flatten)]
common: Common,
#[arg(long)]
kind: String,
},
/// Assert a free-form `role` (an id + a human label) on a participant.
Role {
#[command(flatten)]
common: Common,
#[arg(long = "role-id")]
role_id: String,
#[arg(long = "role-label")]
role_label: String,
},
/// Assert (or `--unset`) an integrity `trait` on a participant. Operator-grade
/// (ADR 036 C3) and never self-granted (ADR 024) — both rejected before any write.
Trait {
#[command(flatten)]
common: Common,
#[arg(long = "trait")]
trait_id: String,
/// Clear the trait (sets `trait_value=false`) instead of setting it.
#[arg(long)]
unset: bool,
},
}
impl AssignKind {
fn common(&self) -> &Common {
match self {
AssignKind::ActorKind { common, .. }
| AssignKind::Role { common, .. }
| AssignKind::Trait { common, .. } => common,
}
}
/// The overlay-kind discriminator the supersede pre-read slots by (mirrors
/// `Overlay::overlay_kind`, but available before the `Overlay` is built).
fn overlay_kind(&self) -> &'static str {
match self {
AssignKind::ActorKind { .. } => "actor-kind",
AssignKind::Role { .. } => "role",
AssignKind::Trait { .. } => "trait",
}
}
/// The `trait_id` this assignment slots by (traits supersede per trait_id);
/// None for actor-kind/role (they supersede per overlay_kind).
fn trait_id(&self) -> Option<&str> {
match self {
AssignKind::Trait { trait_id, .. } => Some(trait_id.as_str()),
_ => None,
}
}
/// Build the typed `Overlay` for this subcommand. `asserter`/`asserter_kind` are
/// hardcoded operator-grade (ADR 036 C3); `supersedes` is resolved by the caller
/// from the current-slot row and threaded in here.
fn overlay(&self, supersedes: Option<String>) -> Overlay {
let subject = self.common().subject.clone();
match self {
AssignKind::ActorKind { kind, .. } => Overlay::ActorKind {
subject,
asserter: "operator".to_string(),
asserter_kind: "operator".to_string(),
actor_kind: kind.clone(),
supersedes,
},
AssignKind::Role {
role_id,
role_label,
..
} => Overlay::Role {
subject,
asserter: "operator".to_string(),
asserter_kind: "operator".to_string(),
role_id: role_id.clone(),
role_label: role_label.clone(),
supersedes,
},
AssignKind::Trait {
trait_id, unset, ..
} => Overlay::Trait {
subject,
asserter: "operator".to_string(),
asserter_kind: "operator".to_string(),
trait_id: trait_id.clone(),
// `--unset` clears the trait (value=false); the default sets it.
value: !*unset,
supersedes,
},
}
}
}
pub fn run(args: AssignArgs) -> CliResult<()> {
let kind = args.kind;
let common = kind.common();
// Resolve the projection target once (ADR 028): `--no-db` => None; `--db <p>` =>
// Explicit (hard-fail on projection error); default => the auto-created cwd
// `.zynk/zynk.db` (soft-degrade). Used BOTH for the supersede pre-read and the
// post-write projection.
let target = crate::db::resolve_projection_target(common.db.as_deref(), common.no_db);
// VALIDATE-BEFORE-WRITE (ADR 029 / trackbrc1 P3): validate the subcommand-local fields
// FIRST, with `supersedes=None`, so an unknown kind/trait, an empty role, or a
// self-granted / non-operator trait rejects here with ZERO side effects — no file AND no
// DB. The supersede pre-read below (`current_overlay_audit_id`) auto-creates/migrates the
// default `.zynk/zynk.db`, so it MUST run AFTER this validation, never before. The
// resolved supersedes is threaded back in when we rebuild the overlay for the write.
kind.overlay(None).validate()?;
// Supersede resolution (ADR 036): if a DB is in play, find the CURRENT-slot overlay
// (the row whose forward pointer is still open) for this (session, subject,
// overlay_kind[, trait_id]); a re-assign / --unset supersedes it, a first assignment
// finds none. Under `--no-db` (file-only) there is no live row to read, so
// supersedes=None — `db import` reconstructs ordering from the committed payloads'
// pointers. The pre-read is non-fatal on a soft-degrade target (a default DB that
// can't be opened just means "no prior row"); an explicit `--db` surfaces the error.
let supersedes = match target.path_and_mode() {
Some((db_path, explicit)) => {
match crate::db::current_overlay_audit_id(
db_path,
&common.session_id,
&common.subject,
kind.overlay_kind(),
kind.trait_id(),
) {
Ok(prev) => prev,
Err(error) if explicit => return Err(error),
// Default target soft-degrade: treat an unreadable DB as "no prior row".
Err(_) => None,
}
}
None => None,
};
// Rebuild the typed overlay with the resolved `supersedes` for the write. Re-validate
// for defensiveness (cheap; the same fields already passed above with supersedes=None,
// and the threaded supersedes does not change the validation outcome).
let overlay = kind.overlay(supersedes);
overlay.validate()?;
// Build the overlay audit (ADR 033 D4 conventions, shared with `decide`): an
// operator-observed, operator-verified, transport=none proof carrying the serialized
// overlay as its full-redaction payload, with `type`=OVERLAY_RECORD_TYPE.
// delivery_status=observed (never `sent` — an overlay is not a transported message),
// so the ADR 024 sent+agent boundary never applies; the v10 sent-guard trigger keeps
// the family out of `sent`.
let audit_args = AuditArgs {
profile: None,
root: common.root.clone(),
session_id: common.session_id.clone(),
audit_id: None,
previous_audit_id: None,
timestamp: common.timestamp.clone(),
due: None,
source_agent: "operator".to_string(),
source_address: "cli".to_string(),
target_agent: "none".to_string(),
target_address: "none".to_string(),
transport: "none".to_string(),
workspace_id: "none".to_string(),
transport_thread_id: None,
mid: crate::dashboard_write::mint_mid(),
record_type: crate::overlay::OVERLAY_RECORD_TYPE.to_string(),
mode: None,
r#ref: None,
re: None,
command_origin: "operator".to_string(),
payload: Some(overlay.to_storage()),
payload_file: None,
payload_redaction_policy: "full".to_string(),
payload_ref: None,
sensitive_category: None,
excerpt_chars: 12,
delivery_status: "observed".to_string(),
observed_by: "operator".to_string(),
verified_by: "operator".to_string(),
db: common.db.clone(),
no_db: common.no_db,
// `zynk assign` does not expose `--retain-custody`; the redacted corpus is
// unchanged (mirrors `zynk decide`).
retain_custody: false,
custody_key_file: None,
};
// Reuse the audit machinery: validate the rendered args (boundary + enums + RFC3339
// timestamp) BEFORE the file write, then write the file (file-first, under the
// audit.md exclusive lock).
let profile = crate::profile::load_profile(None)?;
crate::audit::validate_audit_args(&audit_args, &profile)?;
let (audit_path, record) = crate::audit::write_audit_file(&audit_args, &profile)?;
// Project the overlay (the immutable audit proof row + the typed participant_overlay
// row, atomically, closing the superseded row's forward pointer in the same
// transaction). An explicit `--db` hard-fails on a projection error (the file is
// already durable, then nonzero); the default target soft-degrades on an infra
// failure (the overlay FILE is the authoritative portable record per ADR 027). A
// soft-degrade (or `--no-db`) leaves the typed row absent for now — `db import`
// reconstructs it from the overlay audit's full-redaction payload.
if let Some((db_path, explicit)) = target.into_path_and_mode() {
let result = crate::db::project_overlay(&db_path, &common.root, &record, &overlay);
if explicit {
result?;
} else if let Err(error) = result {
eprintln!(
"warning: DB projection skipped (overlay file written): {}",
error.message
);
}
}
println!("{}", audit_path.display());
Ok(())
}