OXYGENHUB

Seals adopt anonymous changes; oxy seal command 05e71ed6cdf1 resolution

A change whose author is the zero key (Git imports, history from before an
identity existed) is authenticated by a seal from any key, which policy then
authorises as usual (spec §14.4). Pushes adopt such changes in the pusher's
seal; oxy seal [CHANGE].. seals explicitly. Found when pushing oxygen's own
first seven changes to a hub that enforces its policy.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 17:25 UTC
Signature
✔ signed by Andy McCall
Hash
05e71ed6cdf1cd789b178c7fe119ae2b0158a8de2b31020eca30f61ac4fd6f40
Depends on
30ea9c1e70fa 50a046e439e9 5259a3af8ee6 7421309f5372 750251b21a96 75167672ebcd 83e0677b07fe 96f7610fa666 982864068a59 c6aa5bd9d1c8 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
Depended on by
c895bc52e4e0 a2387d547373 cb8b8082fa73 7ba155f20aa0 57eaac9107df d42369abf180 3e5772ccf9da d82e44097323 be0161a88dd7 76f752503e7e 7aa2dc9369e3 b0e64e81e4fb
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
36 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/oxy-core/src/policy.rs +1 −1
…
99 unchanged lines
100 100
            (0, Value::Uint(FORMAT_VERSION)),
101 101
            (1, Value::Bytes(self.repo.to_vec())),
102 102
            (2, Value::Uint(self.serial)),
103 +
            (
103 104
                3,
104 105
                Value::map([
105 106
                    (0, Self::keys_value(&self.roots)),
106 107
                    (1, Value::Uint(self.root_threshold as u64)),
107 108
                ]),
108 109
            ),
109 -
            (
110 110
            (
111 111
                4,
112 112
                Value::Array(
…
339 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +51 −1
…
343 unchanged lines
344 344
            .store
345 345
            .get_identity(&sig.key)
346 346
            .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
347 -
        let authorised = sig.key == change.author
347 +
        // The author-or-delegate rule (spec §14.4). A change whose author is
348 +
        // the zero key names nobody (Git imports, history from before an
349 +
        // identity existed), so any key may adopt it with a seal; policy
350 +
        // then authorises that key for the reference as usual.
351 +
        let authorised = change.author.is_zero()
352 +
            || sig.key == change.author
348 353
            || self
349 354
                .store
350 355
                .get_identity(&change.author)
…
58 unchanged lines
409 414
    }
410 415
411 416
    /// Store and sign an attestation with the configured key.
417 +
    /// Seal changes with the identity key (spec §14.4): a stored, indexed
418 +
    /// seal attestation over `hashes`, pushed with the next push. Returns
419 +
    /// the attestation's reference.
420 +
    pub fn seal(&self, hashes: &[ChangeHash]) -> Result<ObjectRef> {
421 +
        if hashes.is_empty() {
422 +
            return fail("nothing to seal");
423 +
        }
424 +
        if hashes.len() > crate::attest::MAX_SEAL_HASHES {
425 +
            return fail(format!("a seal covers at most {} changes", crate::attest::MAX_SEAL_HASHES));
426 +
        }
427 +
        for h in hashes {
428 +
            if !self.store.has(h) {
429 +
                return fail(format!("unknown change {}", h.short(12)));
430 +
            }
431 +
        }
432 +
        let mut sorted = hashes.to_vec();
433 +
        sorted.sort();
434 +
        sorted.dedup();
435 +
        let sa = self.sign_attest(&Attest::seal(self.id()?, sorted.clone()))?;
436 +
        let r = self.store.put_attest(&sa.attest)?;
437 +
        for sig in &sa.sigs {
438 +
            self.store.put_signature(&r, sig)?;
439 +
        }
440 +
        for h in &sorted {
441 +
            self.store.index_seal(h, &r)?;
442 +
        }
443 +
        Ok(r)
444 +
    }
445 +
446 +
    /// The changes of the current bookmark this identity can authenticate
447 +
    /// but has not: its own unsigned ones and anonymous ones.
448 +
    pub fn sealable(&self) -> Result<Vec<ChangeHash>> {
449 +
        let Some((key, _)) = self.secret_key()? else { return fail("no identity: run oxy identity create") };
450 +
        let heads = self.bookmark_heads()?;
451 +
        let state = self.load_state(&heads)?;
452 +
        let mut out = Vec::new();
453 +
        for (h, meta) in state.changes() {
454 +
            if (meta.author == key || meta.author.is_zero()) && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
455 +
                out.push(*h);
456 +
            }
457 +
        }
458 +
        out.sort();
459 +
        Ok(out)
460 +
    }
461 +
412 462
    pub(crate) fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> {
413 463
        let Some((key, sk)) = self.secret_key()? else {
414 464
            return fail("no secret key: run `oxy identity create` first");
…
15 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +18 −6
…
641 unchanged lines
642 642
    /// Refuse a remote that is another repository. An **unbound** remote
643 643
    /// (id all zero: a hosted repository nobody has pushed to) is accepted
644 644
    /// for pushes, which bind it to this repository's id (spec §16.1).
645 -
    fn check_remote_id(&self, remote: &dyn crate::protocol::Remote, remote_name: &str, allow_unbound: bool) -> Result<()> {
645 +
    fn check_remote_id(
646 +
        &self,
647 +
        remote: &dyn crate::protocol::Remote,
648 +
        remote_name: &str,
649 +
        allow_unbound: bool,
650 +
    ) -> Result<()> {
646 651
        let theirs = remote.repo_id()?;
647 652
        if theirs == self.id()? {
648 653
            return Ok(());
…
2 unchanged lines
651 656
            if allow_unbound {
652 657
                return Ok(());
653 658
            }
654 -
            return fail(format!("remote {remote_name:?} is empty and unbound: push to it first"));
659 +
            return fail(format!(
660 +
                "remote {remote_name:?} is empty and unbound: push to it first"
661 +
            ));
655 662
        }
656 -
        fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"))
663 +
        fail(format!(
664 +
            "remote {remote_name:?} is a different repository (repository IDs differ)"
665 +
        ))
657 666
    }
658 667
659 668
    fn remote_options(&self, name: &str) -> RemoteOptions {
…
527 unchanged lines
1187 1196
        let mut bundle = self.bundle_for(&to_send)?;
1188 1197
        let key = self.secret_key()?.map(|(k, _)| k);
1189 1198
        if let Some(key) = key {
1190 -
            // Seal this author's changes that carry no valid authenticator.
1199 +
            // Seal this author's changes that carry no valid authenticator,
1200 +
            // and adopt anonymous ones (zero author, spec §14.4).
1191 1201
            let mut unsigned = Vec::new();
1192 1202
            for h in &to_send {
1193 1203
                let c = self.store.get_change(h)?;
1194 -
                if c.author == key
1204 +
                if (c.author == key || c.author.is_zero())
1195 1205
                    && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
1196 1206
                {
1197 1207
                    unsigned.push(*h);
…
131 unchanged lines
1329 1339
        let remote = open_remote(url, &options)?;
1330 1340
        let id = remote.repo_id()?;
1331 1341
        if id == crate::protocol::UNBOUND_ID {
1332 -
            return fail(format!("{url} is empty and unbound: push a repository to it first"));
1342 +
            return fail(format!(
1343 +
                "{url} is empty and unbound: push a repository to it first"
1344 +
            ));
1333 1345
        }
1334 1346
        let mut repo = Repo::init_with_id(dest, id)?;
1335 1347
        repo.config.set("remote.origin.url", url)?;
…
32 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +15 −5
…
376 unchanged lines
377 377
        }
378 378
    );
379 379
380 -
    // A signature by a key that is not the author or a delegate is rejected.
380 +
    // A signature by a key that is not the author or a delegate is rejected
381 +
    // when the change names an author; an anonymous change (made before any
382 +
    // identity, author zero) is adopted by whoever signs it (spec §14.4).
381 383
    let other = SecretKey::generate();
382 384
    let other_id = other.identity("Mallory", "[email protected]", 0);
383 385
    let other_key = repo.store().put_identity(&other_id).unwrap();
384 -
    let forged = other.sign(Domain::Change, &other_key, &h0);
385 -
    repo.store().put_signature(&h0, &forged).unwrap();
386 +
    write(root, "a.txt", b"a\nb\nc\nd\n");
387 +
    repo.config.set("core.sign", "seal").unwrap();
388 +
    let h3 = repo.commit("ada's, unsigned", &[]).unwrap();
389 +
    let forged = other.sign(Domain::Change, &other_key, &h3);
390 +
    repo.store().put_signature(&h3, &forged).unwrap();
386 391
    assert!(
387 -
        matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author"))
392 +
        matches!(repo.verify_change(&h3).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author"))
388 393
    );
394 +
    let adopted = other.sign(Domain::Change, &other_key, &h0);
395 +
    repo.store().put_signature(&h0, &adopted).unwrap();
396 +
    assert!(
397 +
        matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Valid { name, .. } if name == "Mallory")
398 +
    );
389 399
390 400
    // Tampering with the stored signature invalidates it.
391 401
    let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
…
1477 unchanged lines
1869 1879
        !a.snapshot("status").unwrap().changed,
1870 1880
        "out-of-scope files are not removals"
1871 1881
    );
1872 -
<<<<<<< oxy conflict 1 of 1, side 1 of 2: deleted by change aa56fdfb "Unbound hosted repositories adopt the first pusher's id"
1882 +
    assert!(!a.quick_status().unwrap().dirty);
1873 1883
-------
1874 1884
    assert!(!a.snapshot("status").unwrap().changed, "out-of-scope files are not removals");
1875 1885
======= oxy conflict 1 of 1, side 2 of 2: change 3d12c43d "Test: assert! instead of assert_eq! on a bool"
…
2000 unchanged lines
modified crates/oxy-server/src/lib.rs +13 −3
…
202 unchanged lines
203 203
            .and_then(hex_id);
204 204
        let server = Arc::clone(self);
205 205
        let result =
206 -
            tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body, claimed)).await;
206 +
            tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body, claimed))
207 +
                .await;
207 208
        match result {
208 209
            Ok(Ok(r)) => r,
209 210
            Ok(Err(f)) => f.into_response(),
…
1 unchanged lines
211 212
        }
212 213
    }
213 214
214 -
    fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes, claimed: Option<[u8; 16]>) -> Result<Response, Failure> {
215 +
    fn respond(
216 +
        &self,
217 +
        name: &str,
218 +
        endpoint: Endpoint,
219 +
        body: Bytes,
220 +
        claimed: Option<[u8; 16]>,
221 +
    ) -> Result<Response, Failure> {
215 222
        let r = self.backend.open(name)?;
216 223
        Ok(match endpoint {
217 224
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
…
37 unchanged lines
255 262
                        None => {
256 263
                            return Err(Failure(
257 264
                                StatusCode::BAD_REQUEST,
258 -
                                format!("this repository is unbound; push with a client that sends {}", oxy_core::protocol::REPO_ID_HEADER),
265 +
                                format!(
266 +
                                    "this repository is unbound; push with a client that sends {}",
267 +
                                    oxy_core::protocol::REPO_ID_HEADER
268 +
                                ),
259 269
                            ))
260 270
                        }
261 271
                    }
…
116 unchanged lines
modified crates/oxy-server/tests/http.rs +69 −3
…
484 unchanged lines
485 485
    assert_eq!(hosted.id().unwrap(), [0; 16]);
486 486
    let base = start(&served, vec![]);
487 487
    let url = format!("{base}/lib");
488 -
    assert!(Repo::clone(&url, &root.join("early")).err().unwrap().to_string().contains("unbound"));
488 +
    assert!(Repo::clone(&url, &root.join("early"))
489 +
        .err()
490 +
        .unwrap()
491 +
        .to_string()
492 +
        .contains("unbound"));
489 493
490 494
    // An existing repository with its own id and history.
491 495
    let work = root.join("work");
…
4 unchanged lines
496 500
    local.commit("a", &[]).unwrap();
497 501
    local.config.set("remote.origin.url", &url).unwrap();
498 502
    local.save_config().unwrap();
499 -
    assert!(local.fetch("origin").err().unwrap().to_string().contains("unbound"), "fetching an unbound remote is refused");
503 +
    assert!(
504 +
        local
505 +
            .fetch("origin")
506 +
            .err()
507 +
            .unwrap()
508 +
            .to_string()
509 +
            .contains("unbound"),
510 +
        "fetching an unbound remote is refused"
511 +
    );
500 512
    let results = local.push("origin", false).unwrap();
501 513
    assert!(results.iter().all(|r| r.ok), "{results:?}");
502 514
…
10 unchanged lines
513 525
    write(&root.join("other"), "b.txt", b"b\n");
514 526
    other.snapshot("test").unwrap();
515 527
    other.commit("b", &[]).unwrap();
516 -
    assert!(other.push("origin", false).err().unwrap().to_string().contains("different repository"));
528 +
    assert!(other
529 +
        .push("origin", false)
530 +
        .err()
531 +
        .unwrap()
532 +
        .to_string()
533 +
        .contains("different repository"));
534 +
}
535 +
536 +
/// History made before an identity existed (zero author) is adopted by
537 +
/// the pusher's seal, so it passes a server that enforces a policy.
538 +
#[test]
539 +
fn anonymous_history_is_adopted_by_the_pushers_seal() {
540 +
    hermetic();
541 +
    let dir = tempfile::tempdir().unwrap();
542 +
    let root = dir.path();
543 +
    let served = root.join("served");
544 +
    Repo::init_bare_unbound(&served.join("app")).unwrap();
545 +
    let base = start(&served, vec![]);
546 +
    let url = format!("{base}/app");
547 +
548 +
    // Two anonymous changes, then an identity and a policy naming it.
549 +
    let work = root.join("work");
550 +
    let mut local = Repo::init(&work).unwrap();
551 +
    write(&work, "a.txt", b"a\n");
552 +
    local.snapshot("test").unwrap();
553 +
    let anon = local.commit("before identity", &[]).unwrap();
554 +
    local.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
555 +
    local.policy_init().unwrap();
556 +
    write(&work, "b.txt", b"b\n");
557 +
    local.snapshot("test").unwrap();
558 +
    let mine = local.commit("after identity", &[]).unwrap();
559 +
    assert!(matches!(local.authenticate_change(&anon).unwrap(), SignatureStatus::Unsigned));
560 +
    local.config.set("remote.origin.url", &url).unwrap();
561 +
    local.save_config().unwrap();
562 +
    let results = local.push("origin", false).unwrap();
563 +
    assert!(results.iter().all(|r| r.ok), "{results:?}");
564 +
565 +
    // The server holds the policy and both changes verify: sealed by the adopter.
566 +
    let hosted = Repo::open(&served.join("app")).unwrap();
567 +
    assert!(hosted.current_policy().unwrap().is_some());
568 +
    for h in [anon, mine] {
569 +
        assert!(matches!(hosted.authenticate_change(&h).unwrap(), SignatureStatus::Valid { sealed: true, name, .. } if name == "Andy"), "{h}");
570 +
    }
571 +
    // A change that names another author still cannot be sealed by someone else.
572 +
    let stranger = root.join("stranger");
573 +
    let mut other = Repo::init(&stranger).unwrap();
574 +
    other.create_identity("Mallory", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
575 +
    write(&stranger, "c.txt", b"c\n");
576 +
    other.snapshot("test").unwrap();
577 +
    let theirs = other.commit("mallory's", &[]).unwrap();
578 +
    assert!(matches!(other.authenticate_change(&theirs).unwrap(), SignatureStatus::Unsigned));
579 +
    let bundle = other.bundle_for(&[theirs]).unwrap();
580 +
    local.import_bundle(&bundle, false).unwrap();
581 +
    local.seal(&[theirs]).unwrap();
582 +
    assert!(matches!(local.authenticate_change(&theirs).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author nor a delegate")));
517 583
}
modified docs/plan.md +1 −0
…
82 unchanged lines
83 83
84 84
- [x] Bare repositories: `Repo::init_bare`, `is_bare`, `oxy init --bare`; `working_copy` refuses; `log`, `log_for_path` work from the bookmark state; `log_state`, `blame_in`, `log_for_path_in` for any state.
85 85
- [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook.
86 +
- [x] Anonymous (zero-author) changes are adopted by the pusher's seal (spec §14.4), found when pushing oxygen's own pre-identity history to the hub.
86 87
- [x] Unbound hosted repositories (`Repo::init_bare_unbound`, `bind_id`, `Oxy-Repo-Id` header): an existing repository pushed to an empty hosted one keeps its id (protocol.md §2).
87 88
- [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2).
88 89
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
…
4 unchanged lines
modified docs/spec.md +4 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8d |
5 +
| **Status** | Draft 0.8e |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
616 unchanged lines
625 625
626 626
**Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.
627 627
628 -
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A seal's signer must additionally be authorized by policy for the reference being pushed.
628 +
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed.
629 629
630 630
**Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys.
631 631
…
199 unchanged lines
831 831
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
832 832
oxy identity create|show|export|rotate|renew|revoke|delegate
833 833
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
834 +
oxy seal [CHANGE]..                 seal your unauthenticated and anonymous changes (§14.4)
834 835
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
835 836
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
836 837
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
…
78 unchanged lines
915 916
916 917
---
917 918
918 -
*Changelog.* Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
919 +
*Changelog.* Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
919 920
920 921
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
921 922
…
59 unchanged lines
modified src/cli/identity.rs +34 −0
…
672 unchanged lines
673 673
    report_update(&u, out)?;
674 674
    Ok(0)
675 675
}
676 +
677 +
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
678 +
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
679 +
    let repo = Repo::open(&std::env::current_dir()?)?;
680 +
    let hashes: Vec<_> = if specs.is_empty() {
681 +
        repo.sealable()?
682 +
    } else {
683 +
        specs.iter().map(|s| repo.resolve_change(s)).collect::<std::result::Result<_, _>>()?
684 +
    };
685 +
    if hashes.is_empty() {
686 +
        if as_json {
687 +
            println!("{{\"sealed\":[]}}");
688 +
        } else {
689 +
            println!("{} nothing to seal: every change you can authenticate already is", ui::done("Sealed"));
690 +
        }
691 +
        return Ok(0);
692 +
    }
693 +
    let mut refs = Vec::new();
694 +
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
695 +
        refs.push(repo.seal(chunk)?);
696 +
    }
697 +
    if as_json {
698 +
        let list: Vec<String> = hashes.iter().map(|h| format!("\"{}\"", h.to_hex())).collect();
699 +
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
700 +
        println!("{{\"sealed\":[{}],\"seals\":[{}]}}", list.join(","), seals.join(","));
701 +
    } else {
702 +
        println!("{} {} change{} in {} seal{}; they travel with your next push", ui::done("Sealed"), hashes.len(), if hashes.len() == 1 { "" } else { "s" }, refs.len(), if refs.len() == 1 { "" } else { "s" });
703 +
        for h in &hashes {
704 +
            let title = repo.store().get_change(h).map(|c| c.message.lines().next().unwrap_or("").to_string()).unwrap_or_default();
705 +
            println!("  {} {}", ui::hash(h.short(12)), title);
706 +
        }
707 +
    }
708 +
    Ok(0)
709 +
}
modified src/cli/mod.rs +7 −0
…
294 unchanged lines
295 295
        #[command(subcommand)]
296 296
        cmd: IdentityCmd,
297 297
    },
298 +
    /// Seal changes with your key: named ones, or every change on the current bookmark you can authenticate but have not
299 +
    Seal {
300 +
        changes: Vec<String>,
301 +
        #[arg(long)]
302 +
        json: bool,
303 +
    },
298 304
    /// Verify the signatures on a change (default: every change in the log)
299 305
    Verify {
300 306
        change: Option<String>,
…
460 unchanged lines
761 767
            },
762 768
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
763 769
        },
770 +
        Cmd::Seal { changes, json } => identity::seal(&changes, json),
764 771
        Cmd::Verify { change, json } => identity::verify(change.as_deref(), json),
765 772
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
766 773
    }
…
1 unchanged lines