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 |