OXYGENHUB

Roadmap across oxygen, oxygen-vscode, oxygenhub, oxygenhub-vscode and the runner b0e64e81e4fb

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 17:49 UTC
Signature
✔ signed by Andy McCall
Hash
b0e64e81e4fb73fd37e6188f78823469392ccdd711354c3bac8946eec63c13df
Depends on
05e71ed6cdf1 750251b21a96 96f7610fa666 982864068a59 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
Depended on by
a2387d547373 8349c327fe83 3526d339519f f556a3ba8a9d ade1b26331d8 3faccd90ddfa 1df1d57a919c 4213c41dfc64 be0161a88dd7 9d333fc9ca1b 119bbf50cb86
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
20 atoms, 1 tree operation, 5 files

Browse the tree at this change

added docs/roadmap.md +48 −0
1 +
# Oxygen roadmap
2 +
3 +
One ordered plan across the products: **oxygen** (`oxy`, the VCS),
4 +
**oxygen-vscode** (the editor extension), **oxygenhub** (the forge),
5 +
**oxygenhub-vscode** (the forge in the editor) and the **runner** (CI).
6 +
Each product keeps its own `docs/plan.md` with the checklists; this file
7 +
is the order and the reasons. Started 2026-09-27.
8 +
9 +
Rules that apply throughout: a phase is done when its tests pass, clippy is
10 +
clean, the change is committed with `oxy` and pushed to the hub on Earth;
11 +
oxygen is never checked into Git; specs are written and reviewed before
12 +
code when the phase changes a security-relevant rule.
13 +
14 +
| Phase | Product | Delivers | Unblocks |
15 +
|---|---|---|---|
16 +
| A | oxygen | Challenge signing, push report, `Send` state snapshot for caching, paginated history window with a shared graph layout | B, everything that caches |
17 +
| B | oxygenhub 0.2 | Accounts, sessions with TOTP, organisations and teams, roles, visibility by viewer, scoped tokens, linked identities, audit log, settings, attributed pushes | C, D |
18 +
| C | review | Adversarial review of oxy §14.4 (seal adoption), §16.1 (unbound repositories), hub §5 and §7 | D |
19 +
| D | oxygen + oxygenhub 0.3 | `oxy seal --proposal`, `oxy proposal show|land`; proposals, projected state, atom-anchored comments, seal-backed approvals, requirements, landing, stacking | E, F |
20 +
| E | oxygenhub 0.4 | Issues, labels, milestones, cross references, inbox, email, watching, search over issues and proposals | F |
21 +
| F | oxygenhub 0.5 + oxygen-vscode | Complete JSON API, `hub-api` types with generated TypeScript, webhooks, checks with seals, SSE status; the extension's exported API and remote-provider hook | G, I |
22 +
| G | oxygenhub-vscode | Spec, then sign-in, proposals, review threads, approve and land, issues, checks in the editor | — |
23 +
| H | oxygen + oxygenhub 0.6 | Delegate-signed changes from an edit set; web edits, browser conflict resolution, releases with assets and archives, Git mirror, code search | — |
24 +
| I | runner | `docs/runner.md`, `oxyrun` running pipelines locally in containers, hub job-claim API, results as seals, required checks | — |
25 +
| J | all 1.0 | oxygen-vscode on three platforms and the Marketplace; hub passkeys, backup drill, load test, security review, packaged release; oxy spec open questions (§28) closed | — |
26 +
27 +
## Phase A: oxygen groundwork
28 +
29 +
1. `oxy identity sign-challenge <hex>` and `oxy_core::identity::{sign_challenge, verify_challenge}` with a domain-separated message, for linking keys and for HTTP signature authentication later. Done when a hub can verify a signed challenge against an exported identity.
30 +
2. `receive_push` returns, per accepted reference, old state, new state and the newly stored changes; `oxy-server` passes it to `after_push`. Done when the hub's push log shows heads and change counts.
31 +
3. A `Send` snapshot type (`state::Frozen` or similar) holding the materialised tree, files and per-change metadata without `Rc`, so a host can cache by heads across threads. Done when the hub caches states and the overview of `oxygen` renders in under 100 ms warm.
32 +
4. `Repo::log_window(heads, cursor, limit)` and `graph::layout` in `oxy-core`, used by the CLI's log and the hub's history page. Done when both draw the same lanes for the same history.
33 +
34 +
## Phase B: oxygenhub 0.2
35 +
36 +
Order inside the phase: schema and password auth first (accounts, sessions, CSRF, sign in and out, profile), then roles and visibility (organisations, teams, collaborator roles, viewer-aware lookup and protocol authorisation), then tokens (scoped, hashed, per-repository), then linked identities (challenge flow, attribution on every page), then TOTP and sessions management, then the audit log and settings pages, and finally the admin pages (registration mode, accounts, storage). Milestone done when a private repository is invisible to outsiders, a push is attributed to the key that signed it, and a token with `repo:read` alone cannot push.
37 +
38 +
## Phase C: review checkpoint
39 +
40 +
Andy commissions the adversarial review. The questions to put to the reviewer: can an attacker who controls the hub forge history, approvals or policy (the design says no); does seal adoption of anonymous changes open a laundering path; can an unbound repository be hijacked before its owner pushes; are the hub's role checks and the repository's policy ever allowed to disagree in the attacker's favour. Findings become plan items before phase D starts.
41 +
42 +
## Phases D to J
43 +
44 +
Each is specified in the product's own spec: hub §7 (D), §8 and §10 (E), §12 and §13.1 (F), the oxygenhub-vscode spec to be written at G, hub §6.9, §9, §14 (H), the runner spec (I), and the 1.0 rows of each milestone table (J). Their checklists are added to the plan files when the phase begins.
45 +
46 +
## Progress
47 +
48 +
- 2026-09-27: roadmap written; phase A started.
modified crates/oxy-core/src/repo/identity.rs +10 −3
…
421 unchanged lines
422 422
            return fail("nothing to seal");
423 423
        }
424 424
        if hashes.len() > crate::attest::MAX_SEAL_HASHES {
425 -
            return fail(format!("a seal covers at most {} changes", crate::attest::MAX_SEAL_HASHES));
425 +
            return fail(format!(
426 +
                "a seal covers at most {} changes",
427 +
                crate::attest::MAX_SEAL_HASHES
428 +
            ));
426 429
        }
427 430
        for h in hashes {
428 431
            if !self.store.has(h) {
…
17 unchanged lines
446 449
    /// The changes of the current bookmark this identity can authenticate
447 450
    /// but has not: its own unsigned ones and anonymous ones.
448 451
    pub fn sealable(&self) -> Result<Vec<ChangeHash>> {
449 -
        let Some((key, _)) = self.secret_key()? else { return fail("no identity: run oxy identity create") };
452 +
        let Some((key, _)) = self.secret_key()? else {
453 +
            return fail("no identity: run oxy identity create");
454 +
        };
450 455
        let heads = self.bookmark_heads()?;
451 456
        let state = self.load_state(&heads)?;
452 457
        let mut out = Vec::new();
453 458
        for (h, meta) in state.changes() {
454 -
            if (meta.author == key || meta.author.is_zero()) && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
459 +
            if (meta.author == key || meta.author.is_zero())
460 +
                && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
461 +
            {
455 462
                out.push(*h);
456 463
            }
457 464
        }
…
22 unchanged lines
modified crates/oxy-server/tests/http.rs +29 −6
…
550 unchanged lines
551 551
    write(&work, "a.txt", b"a\n");
552 552
    local.snapshot("test").unwrap();
553 553
    let anon = local.commit("before identity", &[]).unwrap();
554 -
    local.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
554 +
    local
555 +
        .create_identity(
556 +
            "Andy",
557 +
            "[email protected]",
558 +
            oxy_core::repo::IdentityScope::Repo,
559 +
        )
560 +
        .unwrap();
555 561
    local.policy_init().unwrap();
556 562
    write(&work, "b.txt", b"b\n");
557 563
    local.snapshot("test").unwrap();
558 564
    let mine = local.commit("after identity", &[]).unwrap();
559 -
    assert!(matches!(local.authenticate_change(&anon).unwrap(), SignatureStatus::Unsigned));
565 +
    assert!(matches!(
566 +
        local.authenticate_change(&anon).unwrap(),
567 +
        SignatureStatus::Unsigned
568 +
    ));
560 569
    local.config.set("remote.origin.url", &url).unwrap();
561 570
    local.save_config().unwrap();
562 571
    let results = local.push("origin", false).unwrap();
…
3 unchanged lines
566 575
    let hosted = Repo::open(&served.join("app")).unwrap();
567 576
    assert!(hosted.current_policy().unwrap().is_some());
568 577
    for h in [anon, mine] {
569 -
        assert!(matches!(hosted.authenticate_change(&h).unwrap(), SignatureStatus::Valid { sealed: true, name, .. } if name == "Andy"), "{h}");
578 +
        assert!(
579 +
            matches!(hosted.authenticate_change(&h).unwrap(), SignatureStatus::Valid { sealed: true, name, .. } if name == "Andy"),
580 +
            "{h}"
581 +
        );
570 582
    }
571 583
    // A change that names another author still cannot be sealed by someone else.
572 584
    let stranger = root.join("stranger");
573 585
    let mut other = Repo::init(&stranger).unwrap();
574 -
    other.create_identity("Mallory", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
586 +
    other
587 +
        .create_identity(
588 +
            "Mallory",
589 +
            "[email protected]",
590 +
            oxy_core::repo::IdentityScope::Repo,
591 +
        )
592 +
        .unwrap();
575 593
    write(&stranger, "c.txt", b"c\n");
576 594
    other.snapshot("test").unwrap();
577 595
    let theirs = other.commit("mallory's", &[]).unwrap();
578 -
    assert!(matches!(other.authenticate_change(&theirs).unwrap(), SignatureStatus::Unsigned));
596 +
    assert!(matches!(
597 +
        other.authenticate_change(&theirs).unwrap(),
598 +
        SignatureStatus::Unsigned
599 +
    ));
579 600
    let bundle = other.bundle_for(&[theirs]).unwrap();
580 601
    local.import_bundle(&bundle, false).unwrap();
581 602
    local.seal(&[theirs]).unwrap();
582 -
    assert!(matches!(local.authenticate_change(&theirs).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author nor a delegate")));
603 +
    assert!(
604 +
        matches!(local.authenticate_change(&theirs).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author nor a delegate"))
605 +
    );
583 606
}
modified docs/plan.md +4 −0
…
90 unchanged lines
91 91
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
92 92
- [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then).
93 93
- [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log).
94 +
95 +
## Roadmap
96 +
97 +
The cross-product order of work is in `docs/roadmap.md`.
modified src/cli/identity.rs +30 −6
…
679 unchanged lines
680 680
    let hashes: Vec<_> = if specs.is_empty() {
681 681
        repo.sealable()?
682 682
    } else {
683 -
        specs.iter().map(|s| repo.resolve_change(s)).collect::<std::result::Result<_, _>>()?
683 +
        specs
684 +
            .iter()
685 +
            .map(|s| repo.resolve_change(s))
686 +
            .collect::<std::result::Result<_, _>>()?
684 687
    };
685 688
    if hashes.is_empty() {
686 689
        if as_json {
687 690
            println!("{{\"sealed\":[]}}");
688 691
        } else {
689 -
            println!("{} nothing to seal: every change you can authenticate already is", ui::done("Sealed"));
692 +
            println!(
693 +
                "{} nothing to seal: every change you can authenticate already is",
694 +
                ui::done("Sealed")
695 +
            );
690 696
        }
691 697
        return Ok(0);
692 698
    }
…
2 unchanged lines
695 701
        refs.push(repo.seal(chunk)?);
696 702
    }
697 703
    if as_json {
698 -
        let list: Vec<String> = hashes.iter().map(|h| format!("\"{}\"", h.to_hex())).collect();
704 +
        let list: Vec<String> = hashes
705 +
            .iter()
706 +
            .map(|h| format!("\"{}\"", h.to_hex()))
707 +
            .collect();
699 708
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
700 -
        println!("{{\"sealed\":[{}],\"seals\":[{}]}}", list.join(","), seals.join(","));
709 +
        println!(
710 +
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
711 +
            list.join(","),
712 +
            seals.join(",")
713 +
        );
701 714
    } 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" });
715 +
        println!(
716 +
            "{} {} change{} in {} seal{}; they travel with your next push",
717 +
            ui::done("Sealed"),
718 +
            hashes.len(),
719 +
            if hashes.len() == 1 { "" } else { "s" },
720 +
            refs.len(),
721 +
            if refs.len() == 1 { "" } else { "s" }
722 +
        );
703 723
        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();
724 +
            let title = repo
725 +
                .store()
726 +
                .get_change(h)
727 +
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
728 +
                .unwrap_or_default();
705 729
            println!("  {} {}", ui::hash(h.short(12)), title);
706 730
        }
707 731
    }
…
2 unchanged lines