@staix/agent-hub 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -125,9 +125,11 @@ the same retirement decision.
|
|
|
125
125
|
relayed to the hub console. Loopback is not enough on its own (any web page can open a
|
|
126
126
|
WebSocket to 127.0.0.1), so the control WS requires a per-run token and both hub
|
|
127
127
|
servers refuse requests that carry an `Origin` header (amended in M1).
|
|
128
|
-
9.
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
9. One hub daemon per project directory, with a local bus. Amended by #446:
|
|
129
|
+
opt-in encrypted rooms may connect hubs through an untrusted broker; the room
|
|
130
|
+
boundary does not federate their boards, roles, approvals or memory. X1 defines
|
|
131
|
+
crypto and wire primitives only. X2 supplies a standalone broker; the hub link
|
|
132
|
+
and local delivery integration remain X3/X4. Memory replication through git (#258) remains a separate mechanism.
|
|
131
133
|
10. Shared memory reuses claude-mem as the single store; the hub never runs its own
|
|
132
134
|
memory database. (Amended in #258: the hub owns a long-term store and its graph,
|
|
133
135
|
`docs/memory.md`; claude-mem stays the optional capture and session-recall source.) Native capture stays where it exists (Claude hooks, Codex plugin,
|
|
@@ -706,7 +708,8 @@ M6 internal inference, packaging
|
|
|
706
708
|
|
|
707
709
|
## Out of scope
|
|
708
710
|
|
|
709
|
-
- Cross-machine
|
|
711
|
+
- Windows. Cross-machine rooms are an opt-in extension under #446, described below. The web UI was outside the original six milestones; issue #6 adds the local dashboard described below.
|
|
712
|
+
- Room v1 excludes hosted multi-tenant service, multi-owner rooms, forward secrecy, traffic-metadata hiding, approval or permission-request relay, and cross-machine instances. Shared boards, remote roles and remote task ownership remain excluded; memory crosses only through the separate agent-mem git sync. No hub server is exposed.
|
|
710
713
|
- OmniRoute daemon on the Mac; pooling subscription OAuth through any gateway.
|
|
711
714
|
- Gemini CLI and OpenCode adapters beyond the ACP fallback note.
|
|
712
715
|
- Switchyard `auto` route until v0.3.0 ships.
|
|
@@ -715,6 +718,724 @@ M6 internal inference, packaging
|
|
|
715
718
|
- A hub-owned memory store, cmem.ai cloud sync, automatic corpus building (a project
|
|
716
719
|
corpus for `ahub ask` is an optional M6 item), and any change to the vault LEARN loop.
|
|
717
720
|
|
|
721
|
+
## Encrypted rooms: X1 crypto and wire contract (#446)
|
|
722
|
+
|
|
723
|
+
This amends decision 9 and the original cross-machine exclusion. The [frozen
|
|
724
|
+
D1-D5 design](https://github.com/STAIxBWLB/agent-hub/issues/446#issuecomment-6242775004),
|
|
725
|
+
[person's O1-O4 answers](https://github.com/STAIxBWLB/agent-hub/issues/446#issuecomment-6242885199)
|
|
726
|
+
and [implementation go-ahead](https://github.com/STAIxBWLB/agent-hub/issues/446#issuecomment-6391889660)
|
|
727
|
+
are binding: a self-hosted zero-dependency broker, person-only owner-hub invitations
|
|
728
|
+
and revocations, no task notices in v1, and remote priority capped at `status`.
|
|
729
|
+
X1 adds source modules, with no listeners, CLI, daemon startup imports or room delivery.
|
|
730
|
+
X2 supplies the broker; X3 supplies durable state/link/enrollment integration; X4
|
|
731
|
+
supplies message boundary enforcement; X5-X7 supply status, invocation and the
|
|
732
|
+
person's two-machine live receipt. None of those later proofs follows from X1 tests.
|
|
733
|
+
|
|
734
|
+
### Algorithms and local identity
|
|
735
|
+
|
|
736
|
+
`src/rooms/crypto.ts` uses built-in WebCrypto only: Ed25519 signatures, X25519 key
|
|
737
|
+
agreement, HKDF-SHA-256, AES-256-GCM with a 128-bit tag, and HMAC-SHA-256. Definitions
|
|
738
|
+
follow [WebCrypto](https://www.w3.org/TR/webcrypto-2/), [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748)
|
|
739
|
+
and [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869). `ROOM_MIN_BUN_VERSION` is
|
|
740
|
+
`1.4.2`, also the repository's Bun floor. `assertRoomCryptoReady()` caches a
|
|
741
|
+
fail-closed version/capability self-test, including known-answer and negative
|
|
742
|
+
checks for every primitive. Every asynchronous crypto/wire operation awaits it;
|
|
743
|
+
X3 must also await it before opening any room link. A failure disables rooms,
|
|
744
|
+
without changing local hub operation. Supporting a version requires both the
|
|
745
|
+
version floor and the actual self-test, not a version-string assertion alone.
|
|
746
|
+
|
|
747
|
+
A hub has separate signing and agreement key pairs. Its fingerprint is lowercase
|
|
748
|
+
SHA-256 hex of its raw Ed25519 public key. Both public keys and the fingerprint
|
|
749
|
+
are authenticated together by joins, wraps and rosters. Private runtime handles
|
|
750
|
+
are opaque WeakMap-backed identities; explicit `exportHubIdentityForStorage` and
|
|
751
|
+
`exportEpochKeyForStorage` are machine-local persistence boundaries, never wire
|
|
752
|
+
fields or diagnostics. X3 must store them in a private 0700 directory and 0600
|
|
753
|
+
files. A same-user process can steal those files; pinning is not a sandbox.
|
|
754
|
+
|
|
755
|
+
A random 256-bit epoch root is wrapped separately for each member using a fresh
|
|
756
|
+
X25519 ephemeral pair, 32-byte HKDF salt and 12-byte GCM nonce. HKDF info is SHA-256 of the full
|
|
757
|
+
versioned, domain-separated derivation transcript, keeping provider input bounded
|
|
758
|
+
without dropping context; GCM AAD and signatures still bind the full transcript. The owner's
|
|
759
|
+
signature binds the full owner/recipient public-key tuples, room/epoch/membership
|
|
760
|
+
context, ephemeral key, salt, nonce and ciphertext. Zero shared secrets refuse.
|
|
761
|
+
Every roster change, including label or exposure changes, advances the epoch and
|
|
762
|
+
creates a fresh root. A removed member receives no new wrap. This has no forward
|
|
763
|
+
secrecy against later theft of a member's long-term agreement key and recorded wraps.
|
|
764
|
+
|
|
765
|
+
### Wire v1 and privacy
|
|
766
|
+
|
|
767
|
+
`src/rooms/wire.ts` defines strict canonical UTF-8 JSON: recursively sorted ASCII
|
|
768
|
+
object keys, exact known fields, safe integer timestamps, positive canonical
|
|
769
|
+
uint64 decimal strings for epochs/sequences, and unpadded canonical base64url.
|
|
770
|
+
Decoders reject unknown/duplicate fields, whitespace or alternative encodings,
|
|
771
|
+
invalid UTF-8, unsorted/duplicate collections and exceeded ceilings. Canonical
|
|
772
|
+
bytes are the signed/hashed transcript; signatures use separate versioned domains
|
|
773
|
+
for frames, rosters, wraps and joins. IDs/labels are bounded ASCII identifiers,
|
|
774
|
+
never arbitrary display text. Errors contain fixed codes, no input or key material.
|
|
775
|
+
|
|
776
|
+
| Record or collection | v1 ceiling |
|
|
777
|
+
| --- | --- |
|
|
778
|
+
| Plain frame payload | 65,536 bytes |
|
|
779
|
+
| Encoded frame | 131,072 bytes |
|
|
780
|
+
| Encoded roster | 524,288 bytes |
|
|
781
|
+
| Invite or join | 16,384 bytes |
|
|
782
|
+
| Members, frame recipients, exposed local peer ids per member | 64 each |
|
|
783
|
+
| Local label or exposed peer id | 32 characters |
|
|
784
|
+
| Broker URL | 2,048 characters |
|
|
785
|
+
|
|
786
|
+
These are protocol ceilings, not measured capacity claims. Raising one requires a
|
|
787
|
+
versioned contract review. Broker URLs are canonical `wss:` without credentials or
|
|
788
|
+
fragments; `ws:` is permitted only for literal `127.0.0.1` or `[::1]` testing.
|
|
789
|
+
|
|
790
|
+
The signed roster has public room/epoch/owner, member fingerprints and public keys,
|
|
791
|
+
wraps, and an encrypted private roster. The encrypted portion contains the matching
|
|
792
|
+
member tuples, unique local labels and exposed peer ids. This resolves the freeze's
|
|
793
|
+
signed-roster requirement without exposing labels or peer ids to the broker. All
|
|
794
|
+
public and encrypted components are covered by the owner signature. A receiver
|
|
795
|
+
pins room, owner fingerprint and its persisted minimum epoch. Initial enrollment
|
|
796
|
+
uses `minEpoch="0"`; positive values name an installed epoch, whose exact digest
|
|
797
|
+
is mandatory when accepting that same epoch again. Missing or mismatched digests
|
|
798
|
+
refuse even an otherwise valid owner-signed retry. It verifies the signature, its exact key tuple, wrap,
|
|
799
|
+
private roster and identical membership before accepting a candidate. X3 must
|
|
800
|
+
atomically install that candidate against its persisted pin. Replaying an old
|
|
801
|
+
candidate must never reset the minimum epoch or a grace deadline.
|
|
802
|
+
|
|
803
|
+
A frame's public header is `{v,type,room,epoch,sender,recipients,seq}` plus nonce,
|
|
804
|
+
ciphertext and signature. Recipients are hub fingerprints, not local peer ids.
|
|
805
|
+
The body is opaque encrypted bytes, including any eventual local addressing,
|
|
806
|
+
kind, trace and text. Frame AAD is exactly UTF-8 `room|epoch|sender|seq`. A
|
|
807
|
+
sender-specific AES key is HKDF-derived from the epoch root and room/epoch/sender/
|
|
808
|
+
roster digest context. Every encryption attempt draws a fresh random 96-bit nonce
|
|
809
|
+
from WebCrypto, independent of the sequence counter. A counter rollback therefore
|
|
810
|
+
does not deterministically reuse a GCM nonce; per-sender subkeys also separate
|
|
811
|
+
senders. Random-nonce collision bounds and a functioning runtime RNG remain
|
|
812
|
+
cryptographic assumptions. The sender's
|
|
813
|
+
signature additionally binds recipients, nonce, ciphertext and all header fields.
|
|
814
|
+
X1 encryption authenticates bytes; X4 must still screen what those bytes may mean
|
|
815
|
+
before any local bus delivery, including untrusted framing and the `status` ceiling.
|
|
816
|
+
|
|
817
|
+
### Required durable host ports and epoch settlement
|
|
818
|
+
|
|
819
|
+
There is no production in-memory fallback. `sealFrame` requires `SequencePort`:
|
|
820
|
+
`reserve` durably consumes a strictly increasing room/sender counter that survives
|
|
821
|
+
restart and epoch rotation, checking the installed roster digest. `isCurrent` is
|
|
822
|
+
a final synchronous fence after crypto; X3 must additionally fence durable outbox
|
|
823
|
+
publication. Failed attempts consume sequence numbers; each encryption attempt draws a fresh random nonce. Counter durability still protects replay/order state, rather than determining encryption nonces.
|
|
824
|
+
|
|
825
|
+
`openFrame` verifies current sender/recipient membership, signature, epoch, nonce
|
|
826
|
+
length and GCM authentication before invoking `ReplayPort.accept`. In one transaction that
|
|
827
|
+
port must recheck current and selected roster digests, current membership and its
|
|
828
|
+
own local clock/grace deadline, compare the room/sender high-water mark, and commit
|
|
829
|
+
accepted digest plus inbox delivery before release. Only `accepted` returns
|
|
830
|
+
plaintext. `duplicate` means an identical previously accepted frame digest and
|
|
831
|
+
returns no plaintext; an unknown lower sequence is `stale`, never a completed ACK.
|
|
832
|
+
D5 receipts may be resent only for that stored identical digest. Gaps are recorded,
|
|
833
|
+
not treated as proof that missing messages arrived. Callback failure refuses.
|
|
834
|
+
|
|
835
|
+
Only the immediately preceding epoch may have a persisted 60-second local grace
|
|
836
|
+
deadline. X3 must derive and persist that deadline as the local successful rotation
|
|
837
|
+
installation time plus exactly 60,000 ms, never from caller or remote expiry input. Both sender and receiver must still be current members with unchanged
|
|
838
|
+
keys; revocation therefore refuses immediately, including during grace. The port
|
|
839
|
+
must refuse after expiry even if crypto crossed the deadline, and restarts or
|
|
840
|
+
replayed rosters must not extend it. X1's optional `previous` and `nowMs` parameters
|
|
841
|
+
are trusted-host inputs/test clocks, not remote authority. No caller can substitute
|
|
842
|
+
for X3's durable atomic installation, clock and publication fences.
|
|
843
|
+
|
|
844
|
+
An out-of-band invite carries room, broker, pinned owner fingerprint, expiry,
|
|
845
|
+
random invite id and random 256-bit one-time secret. Never send it to the broker or
|
|
846
|
+
log it. A join exposes only its member public-key tuple and encrypted private
|
|
847
|
+
label/exposure data. HMAC binds the full invitation claims and signed request;
|
|
848
|
+
the member signature binds the same claims and request. Before consuming the
|
|
849
|
+
invite, a trial X25519 agreement rejects unusable/low-order member public keys.
|
|
850
|
+
This checks key usability, without claiming a separate private-key possession
|
|
851
|
+
proof. A broker cannot replace keys or repurpose a request under a changed invitation. Owner expiry is exact;
|
|
852
|
+
a joiner's preflight allows at most 10 minutes of clock skew, which grants no
|
|
853
|
+
owner-side extension. `EnrollmentPort.consume` must compare the original stored
|
|
854
|
+
invite digest and expiry, consume once, and persist identity/request outcome with
|
|
855
|
+
membership publication. Only the exact same request may be a duplicate. Owner-hub
|
|
856
|
+
person authorization and OOB secret handling remain X3 integration obligations.
|
|
857
|
+
|
|
858
|
+
### Acceptance and limits
|
|
859
|
+
|
|
860
|
+
Real built-in crypto tests cover vectors, private/public binding, wrap tampering,
|
|
861
|
+
wrong recipient/context, revoked old-key failure, domain separation and sanitized
|
|
862
|
+
errors. Wire fixtures cover canonical bounds, owner pins, metadata hiding,
|
|
863
|
+
unsigned/tampered frames, replay/concurrent duplicates, stale sequences, epoch
|
|
864
|
+
rotation, current-member grace, invite substitution/expiry/one-time consumption
|
|
865
|
+
and refused state ports. Memory fake ports prove API ordering only, not durability
|
|
866
|
+
or restart safety. Full gate and exact-head CI are separate from native/account or
|
|
867
|
+
two-machine receipts. No room transport, real invite, remote delivery, production
|
|
868
|
+
storage permission, crash-restart replay or X7 receipt is verified by this slice.
|
|
869
|
+
|
|
870
|
+
## Encrypted rooms: X2 blind broker (#446)
|
|
871
|
+
|
|
872
|
+
The frozen O1 is self-hosted, zero-dependency `ahub broker serve`. X2 adds a separate
|
|
873
|
+
Bun HTTP/WebSocket process. The hub daemon, local bus, board, roles, approvals and
|
|
874
|
+
memory are untouched. X3 supplies the hub link, person-only room operations and
|
|
875
|
+
one-time enrollment; X4 supplies framing/privacy/priority enforcement. X2 auth
|
|
876
|
+
proves a hub signing identity against membership, not a remote person's consent.
|
|
877
|
+
There is no invitation-secret or pre-enrollment endpoint here: X3 must supply that
|
|
878
|
+
control path before an invited hub opens its member data connection.
|
|
879
|
+
|
|
880
|
+
### Process and public verification
|
|
881
|
+
|
|
882
|
+
The command is `ahub broker serve [--hostname <host>] [--port <0..65535>]
|
|
883
|
+
[--public-url <ws[s]://host/>] [--owner <fingerprint>]... [--state-dir <dir>]`. Agent markers and `--live-test`
|
|
884
|
+
delegation refuse before importing the command. Help/parser/audit labels agree;
|
|
885
|
+
audit labels never contain path operands. Default bind is `127.0.0.1:8788`; port 0
|
|
886
|
+
asks the OS for an ephemeral fixture port. Default state is `broker/` beneath the
|
|
887
|
+
selected hub state directory, with an explicit machine-local override available.
|
|
888
|
+
|
|
889
|
+
The reference listener uses HTTP/WS behind operator-managed TLS termination.
|
|
890
|
+
Public audience is a canonical root URL without credentials, query or fragment;
|
|
891
|
+
`ws:` is loopback-only, and a non-loopback bind requires an explicit WSS audience.
|
|
892
|
+
Proxy `/v1/rooms/<room>` as binary WebSocket and preserve `Origin`. Every HTTP
|
|
893
|
+
request/upgrade carrying any Origin header, even empty, refuses. `GET /health`
|
|
894
|
+
returns fixed version/status only. Audience comes from startup config, never Host.
|
|
895
|
+
No proxy, certificate or firewall is configured by this command, and fixture
|
|
896
|
+
success is not a live TLS receipt.
|
|
897
|
+
|
|
898
|
+
`--owner` is a repeatable exact signing-fingerprint allowlist for first-roster
|
|
899
|
+
admission. Public WSS audiences (including loopback behind a proxy) and non-loopback
|
|
900
|
+
binds require a nonempty allowlist before storage/listener effects. Other owners
|
|
901
|
+
cannot consume a room slot or squat a room id. Existing room membership remains
|
|
902
|
+
pinned and is not revoked merely by changing this creation allowlist. Loopback with
|
|
903
|
+
no public audience may omit it for development; that open mode is a ceiling, not a
|
|
904
|
+
public service configuration. Do not expose open mode through a proxy.
|
|
905
|
+
|
|
906
|
+
`startBroker` awaits X1's minimum-Bun/capability self-test before database/listener
|
|
907
|
+
effects. `verifyPublicRoster` and `verifyPublicFrame` share X1's canonical signature
|
|
908
|
+
and pin checks. Their immutable WeakMap-issued products contain public ids, keys,
|
|
909
|
+
epoch/digest metadata only; byte accessors copy their storage, Buffer included.
|
|
910
|
+
They carry no epoch root, private identity, plaintext labels/exposures or body.
|
|
911
|
+
The broker never unwraps a key or opens ciphertext.
|
|
912
|
+
|
|
913
|
+
### Socket protocol
|
|
914
|
+
|
|
915
|
+
Input is binary canonical UTF-8 JSON: control cap 1 MiB, challenge cap 8 KiB, with
|
|
916
|
+
X1's smaller roster/frame caps still enforced. Unknown fields, alternate encoding,
|
|
917
|
+
text messages and invalid signatures refuse with fixed codes, no reflected input.
|
|
918
|
+
|
|
919
|
+
1. `/v1/rooms/<room>` emits `{v:1,type:"challenge",room,broker,nonce,expiresAt,rosterDigest}`.
|
|
920
|
+
Nonce is 32 random bytes; deadline is local issue time + 10 seconds. Digest is
|
|
921
|
+
current pinned roster digest, or null for a new room.
|
|
922
|
+
2. The hub replies `{v:1,type:"auth",member,roster,signature}`. New rooms require
|
|
923
|
+
base64url owner-signed roster bytes and the owner's private-key proof. Existing
|
|
924
|
+
rooms require `roster:null` and a signer in the current pinned membership.
|
|
925
|
+
A non-owner cannot bootstrap a former member's old roster into a fresh room.
|
|
926
|
+
3. The Ed25519 transcript is canonical bytes of
|
|
927
|
+
`["agent-hub.rooms.broker.v1","auth",challenge,member,initialRosterDigestOrNull]`.
|
|
928
|
+
The existing digest is already in the challenge; the final array item is null
|
|
929
|
+
for existing rooms. The client builder requires trusted expected broker URL and
|
|
930
|
+
room. Thus audience, room, socket nonce, deadline, digest and member are bound.
|
|
931
|
+
The server consumes its one-use challenge before await, then rechecks expiry,
|
|
932
|
+
current membership/digest and socket generation before effects.
|
|
933
|
+
4. The broker sends current signed roster bytes and
|
|
934
|
+
`{v:1,type:"ready",room,member,epoch,digest}`. The client verifies the roster and
|
|
935
|
+
sends `{v:1,type:"roster_ready",digest}` before queue delivery. This ACK is flow
|
|
936
|
+
control, not proof of epoch-key possession. New rosters require a fresh ACK;
|
|
937
|
+
stale ACKs cannot restore authorization. One socket per room/hub is current;
|
|
938
|
+
replacement closes the older socket and fences its awaited work.
|
|
939
|
+
|
|
940
|
+
Only an authenticated owner publishes newer X1 rosters. Owner fingerprint stays
|
|
941
|
+
pinned; older epochs and same-epoch digest changes refuse. Exact replays are
|
|
942
|
+
idempotent. An adjacent predecessor alone has grace: persisted successful local
|
|
943
|
+
installation time + 60,000 ms. Epoch gaps get no predecessor grace. Replays and
|
|
944
|
+
restart never extend the deadline. Removed/key-changed members disconnect; their
|
|
945
|
+
sender floors and inappropriate queued recipients are purged. During grace only
|
|
946
|
+
still-current sender/receiver keys authorize old frames.
|
|
947
|
+
|
|
948
|
+
### Bounded ciphertext queue
|
|
949
|
+
|
|
950
|
+
A raw X1 frame must match its authenticated socket's sender. After signature awaits,
|
|
951
|
+
the latest socket generation/current membership is rechecked. Store admission
|
|
952
|
+
again checks selected current/previous digest, grace clock, replay floor and caps
|
|
953
|
+
inside a synchronous transaction before writes. No unauthenticated frame persists
|
|
954
|
+
or fans out. Control and frame processing is serialized per socket, so ACKs may
|
|
955
|
+
wait behind an outgoing signature check without racing authentication state.
|
|
956
|
+
|
|
957
|
+
| V1 ceiling | Value |
|
|
958
|
+
| --- | --- |
|
|
959
|
+
| Rooms | 64 |
|
|
960
|
+
| Open sockets / concurrent handlers | 128 / 16 |
|
|
961
|
+
| Pending input per socket | 8 packets, 2 MiB |
|
|
962
|
+
| Pending input globally | 32 MiB |
|
|
963
|
+
| Queued packets per room / globally | 200 / 1,000 |
|
|
964
|
+
| Queued packet bytes per room / globally | 4 MiB / 32 MiB |
|
|
965
|
+
| Queue TTL | 24 hours |
|
|
966
|
+
| Per-socket native backpressure limit | 1 MiB plus the final message allowance |
|
|
967
|
+
| Roster ACK deadline | 30 seconds |
|
|
968
|
+
|
|
969
|
+
Storage holds original sealed wire bytes plus public recipient fingerprints. It
|
|
970
|
+
commits before `{type:"stored",digest,seq,duplicate}`. Sender sequence/digest floors
|
|
971
|
+
survive queue ACKs/restart: the last accepted digest or an identical retained older
|
|
972
|
+
packet is idempotent without requeueing; conflicting or
|
|
973
|
+
unknown lower sequences refuse. FIFO uses SQLite admission row order, never lexical
|
|
974
|
+
sequence strings or clock ties. Full queues refuse new packets without evicting
|
|
975
|
+
accepted ones. Prior-epoch grace may expire a packet before its ordinary TTL.
|
|
976
|
+
|
|
977
|
+
One packet per recipient socket is in flight. `{v:1,type:"received",digest}` removes
|
|
978
|
+
only that identity from the packet's remaining recipients, then returns
|
|
979
|
+
`{type:"queue_ack",digest}` and pumps the next packet. Unknown ACKs change nothing.
|
|
980
|
+
These are transport/storage ACKs, not D5's signed recipient application receipt;
|
|
981
|
+
X3 must handle completion and uncertainty in its journal. Disconnect/backpressure
|
|
982
|
+
retains only bounded ciphertext. Revocation, replacement, expiry and shutdown fence
|
|
983
|
+
pending work before storage or fan-out.
|
|
984
|
+
|
|
985
|
+
The versioned built-in SQLite store uses DELETE journal and EXTRA synchronous
|
|
986
|
+
commits, a private 0700 directory and 0600 database. Exclusive mode retains kernel
|
|
987
|
+
ownership until close; second processes fail closed, and crash releases ownership
|
|
988
|
+
without stale-lock unlink. Existing empty, malformed or unsupported state refuses,
|
|
989
|
+
never silently initializes new pins. Restore validates signed rosters/frames,
|
|
990
|
+
metadata and floors before delivery; expired grace is normal cleanup. See
|
|
991
|
+
[SQLite locking mode](https://www.sqlite.org/pragma.html#pragma_locking_mode) and
|
|
992
|
+
[exclusive access](https://www.sqlite.org/atomiccommit.html#exclusive_access_mode).
|
|
993
|
+
Native buffering includes the [Bun final-message allowance](https://bun.com/docs/runtime/http/websockets).
|
|
994
|
+
|
|
995
|
+
Schema v2 stores each queued packet's epoch as indexed metadata and has an expiry
|
|
996
|
+
index. Known v1 state migrates in place, retaining pins, floors, ciphertext and
|
|
997
|
+
admission order. Ordinary current lookups are pure; next/ACK use targeted metadata
|
|
998
|
+
expiry and only read the selected ciphertext, while timer/admission sweeps expire
|
|
999
|
+
indexed rows and bounded room pins without parsing every packet. Rotation inspects
|
|
1000
|
+
only the changed room's authenticated metadata. Newly admitted packets obey the
|
|
1001
|
+
4 MiB per-room share; existing valid larger queues are grandfathered and block new
|
|
1002
|
+
admission until ACK/expiry reduces them, never evicted to retrofit the share.
|
|
1003
|
+
|
|
1004
|
+
Existing populated directories are accepted only as private recognized broker
|
|
1005
|
+
state; unrelated files or non-0700 mode refuse without changing contents or mode.
|
|
1006
|
+
Empty directories may be tightened to 0700. Offline operations require an existing
|
|
1007
|
+
database and never create a new store to repair a misspelled path.
|
|
1008
|
+
|
|
1009
|
+
`ahub broker room drop <room> --yes [--state-dir <dir>]` is person-only, including
|
|
1010
|
+
under `--live-test`, and requires the broker stopped: the same exclusive database
|
|
1011
|
+
lock refuses an online attempt. It transactionally forgets only the named room's
|
|
1012
|
+
pins, sender floors and queued packets, freeing its slot; other rooms stay intact.
|
|
1013
|
+
This is an explicit broker-side room reset, not member revocation, secure erase,
|
|
1014
|
+
or an application-delivery receipt. Clients retain their own pins/replay state.
|
|
1015
|
+
Remove an unwanted owner from the creation allowlist before restarting, otherwise
|
|
1016
|
+
that still-approved owner may bootstrap the dropped id again. Use a fresh room
|
|
1017
|
+
lifecycle rather than deleting the whole database to regain capacity.
|
|
1018
|
+
|
|
1019
|
+
Logs/stats contain fixed event/error codes, public ids, counts and byte totals.
|
|
1020
|
+
Canary fixtures separate the encrypting client from the broker process and inspect
|
|
1021
|
+
transmitted bytes, retained ciphertext, database and logs. Only clients open the
|
|
1022
|
+
body. This is an input/retained-state invariant, not a full runtime heap-dump
|
|
1023
|
+
receipt or a claim that malicious input cannot deliberately contain plaintext.
|
|
1024
|
+
Traffic analysis, availability attacks, malicious owners, same-user compromise,
|
|
1025
|
+
hosted multi-tenant accounts and forward secrecy remain frozen exclusions.
|
|
1026
|
+
X3/X4 integration and live TLS/account/X7 two-machine receipts remain separate.
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
## Encrypted rooms: X3 outbound membership and presence (#446)
|
|
1030
|
+
|
|
1031
|
+
X3 adds a machine-local `RoomStore`, an outbound `RoomLink` and person-only room
|
|
1032
|
+
administration. The broker remains the X2 blind relay. No listener, remote bus
|
|
1033
|
+
peer, chat egress, task sharing, permission relay or cloud/model call is added.
|
|
1034
|
+
X4 still owns envelope screening, remote identity delivery, signed application
|
|
1035
|
+
receipts and the application journal's completion/uncertainty lifecycle. X5 owns
|
|
1036
|
+
room dashboard integration; X7 owns the person's two-machine live receipt.
|
|
1037
|
+
|
|
1038
|
+
### Person commands and out-of-band enrollment
|
|
1039
|
+
|
|
1040
|
+
The person runs these commands in an ordinary terminal, with a running local hub:
|
|
1041
|
+
|
|
1042
|
+
- `ahub room create <name> --broker <ws[s]://host/> [--label <id>]`
|
|
1043
|
+
- `ahub room invite <name>`
|
|
1044
|
+
- `ahub room join <token> [--name <name>] [--label <id>]`
|
|
1045
|
+
- `ahub room invite <name> --accept <request>`
|
|
1046
|
+
- `ahub room join <name> --roster <signed-roster>`
|
|
1047
|
+
- `ahub room leave <name>`
|
|
1048
|
+
- `ahub room revoke <name> <fingerprint>`
|
|
1049
|
+
- `ahub room expose <name> [<peer>...]`
|
|
1050
|
+
- `ahub room list [--json]` and `ahub room status [<name>] [--json]`
|
|
1051
|
+
|
|
1052
|
+
Names, labels and exposed ids are bounded ASCII identifiers. The label defaults
|
|
1053
|
+
to `hub-<fp8>`, never a hostname. Broker audiences are canonical root WSS URLs;
|
|
1054
|
+
plain WS is allowed only for literal loopback. Read forms publish only local room
|
|
1055
|
+
names, room ids, owner/member fingerprints, epochs and link states. Agent shells
|
|
1056
|
+
may read them. All mutations refuse agent markers and `--live-test`, including
|
|
1057
|
+
conductor delegation. Daemon mutations require a console connection, and every
|
|
1058
|
+
operation, unknown or refused included, answers its `rid`.
|
|
1059
|
+
|
|
1060
|
+
A mutation first prepares a connection-bound, one-use, 30-second ticket. The CLI
|
|
1061
|
+
prints both owner and member fingerprints before executing it. The person must
|
|
1062
|
+
compare them and explicitly confirm: a TTY asks y/N (default No), and a non-TTY
|
|
1063
|
+
requires `--confirm <member-fingerprint-prefix>` with 16..64 matching lowercase hex
|
|
1064
|
+
characters. No, EOF, missing or mismatched confirmation executes nothing. The
|
|
1065
|
+
daemon also matches confirmation to the ticket's member fingerprint. The ticket binds
|
|
1066
|
+
the captured action and arguments; execute accepts no replacement action/input.
|
|
1067
|
+
Socket ownership, shutdown and recovery holds are rechecked after crypto awaits
|
|
1068
|
+
and inside the final state transaction. Preview does not install a room or consume
|
|
1069
|
+
an invitation. Creating a private local identity is not room enrollment.
|
|
1070
|
+
|
|
1071
|
+
The [X3 operand ruling](https://github.com/STAIxBWLB/agent-hub/issues/446) keeps
|
|
1072
|
+
pre-enrollment out of the broker:
|
|
1073
|
+
|
|
1074
|
+
- The owner creates the room and prints an invitation with `invite <name>`. Only
|
|
1075
|
+
this invitation contains the one-time secret. Share it privately out of band,
|
|
1076
|
+
never with the broker, repository, shared logs or an agent.
|
|
1077
|
+
- The joining person runs `join <token>` and returns the printed signed/HMAC'd
|
|
1078
|
+
request to the owner out of band. It contains neither the invitation secret nor
|
|
1079
|
+
any private key. The pending invitation pins room, broker and owner locally.
|
|
1080
|
+
- The owner runs `invite <name> --accept <request>`. HMAC, signature, member keys,
|
|
1081
|
+
expiry and the invitation's original digest are verified before one transaction
|
|
1082
|
+
consumes it and installs the next signed roster. The committed roster is the
|
|
1083
|
+
recoverable publication; the outbound link sends those same persisted bytes.
|
|
1084
|
+
An identical accepted retry does not consume or rotate again. Changed requests
|
|
1085
|
+
refuse. Owner expiry is exact; joiner preflight allows the X1 ten-minute skew.
|
|
1086
|
+
- The joining person imports the returned roster with `join <name> --roster ...`.
|
|
1087
|
+
Owner authentication comes from its own pending invitation, never from the
|
|
1088
|
+
broker or the offered roster itself. Exact own signing/agreement keys must be
|
|
1089
|
+
included. Subsequent explicit roster imports use the retained owner/epoch pin.
|
|
1090
|
+
- A retry after saving a pending request returns the identical saved request when
|
|
1091
|
+
token, label and exposure selection agree, including after a lost CLI reply.
|
|
1092
|
+
A changed pending selection refuses. `leave` cancels a pending enrollment so the
|
|
1093
|
+
person can retry without editing private state. Expired pending entries are
|
|
1094
|
+
bounded and pruned on new admission.
|
|
1095
|
+
|
|
1096
|
+
A non-owner's `expose` prints a signed exposure request, bound to its installed
|
|
1097
|
+
room, owner and roster digest. The member sends it out of band; the owner accepts
|
|
1098
|
+
it with the same `invite --accept` form and returns the next signed roster. It
|
|
1099
|
+
cannot change another member's exposure or be replayed after rotation. A later
|
|
1100
|
+
slice may fetch an already signed roster from the broker and remove the manual
|
|
1101
|
+
`--roster` step; this slice does not add that endpoint or trust the broker to enroll.
|
|
1102
|
+
|
|
1103
|
+
`rooms.<name>.expose` lives only in an untracked `.agenthub/config.local.json`.
|
|
1104
|
+
Shared/committed configuration and generic settings cannot enable it. The live
|
|
1105
|
+
local policy and the verified roster are intersected before any presence is sent;
|
|
1106
|
+
loss of config trust removes exposure. Writable local policy is checked before
|
|
1107
|
+
the owner's own exposure rotation, not only when saving the policy afterward. Empty exposure is the default. Room keys,
|
|
1108
|
+
broker binding, pending invites and membership are private machine-local state,
|
|
1109
|
+
not project config or synced memory. A hub stop closes links and retains membership.
|
|
1110
|
+
`leave` disconnects locally and retains owner/epoch/replay pins; an owner uses
|
|
1111
|
+
`revoke` to remove a former member from the signed room and rekey other members.
|
|
1112
|
+
A person may explicitly import a pinned signed roster to reactivate a left room;
|
|
1113
|
+
automatic link traffic cannot reactivate it.
|
|
1114
|
+
|
|
1115
|
+
### Durable state and outbound link
|
|
1116
|
+
|
|
1117
|
+
`<hub-state>/rooms/rooms.db` is schema version 1, SQLite DELETE/EXTRA with exclusive
|
|
1118
|
+
process ownership and a 1 ms busy bound. Directory mode is 0700 and database mode
|
|
1119
|
+
0600. Symlinks, hardlinked files, unrelated contents, unknown schema, corrupt or
|
|
1120
|
+
unverifiable existing pins refuse, never initialize a new identity over old state.
|
|
1121
|
+
Private keys remain opaque at runtime; only X1's explicit local storage codec is
|
|
1122
|
+
used. This protects against other users, not a hostile same-user process.
|
|
1123
|
+
|
|
1124
|
+
Epoch installation compares the captured current digest in a transaction, refuses
|
|
1125
|
+
rollback and same-epoch digest changes, and preserves only the adjacent predecessor
|
|
1126
|
+
for local successful install time + exactly 60,000 ms. Restart/replayed rosters do
|
|
1127
|
+
not extend grace. A persisted local clock high-water refuses rollback. Sequence
|
|
1128
|
+
reservation commits before sealing and never resets on rotation. Replay atomically
|
|
1129
|
+
checks current membership/keys, installed digests, local grace clock and sender
|
|
1130
|
+
high-water, and persists the accepted digest plus inbox before returning plaintext.
|
|
1131
|
+
Identical retained receipts may be duplicate; unseen lower sequences stay stale.
|
|
1132
|
+
Gaps are recorded. Inbox bodies are removed after validated presence observation;
|
|
1133
|
+
bounded digest receipts remain for duplicate handling.
|
|
1134
|
+
|
|
1135
|
+
Limits are 64 rooms, 128 invitations, 64 pending joins, 200 pending inbox records
|
|
1136
|
+
per room, 16 MiB aggregate inbox bytes, 1,000 retained digest receipts per room,
|
|
1137
|
+
256 retained historical sender floors per room (new senders refuse at the cap),
|
|
1138
|
+
and host outbox ports capped at 200 records per room/16 MiB aggregate. Outbox broker
|
|
1139
|
+
storage is only `accepted`, never application `completed`. X3 presence is ephemeral
|
|
1140
|
+
and uses durable sequence/replay without filling the application outbox. Signed
|
|
1141
|
+
application completion, nack and needs-review expiry are activated with X4 delivery.
|
|
1142
|
+
|
|
1143
|
+
Every link awaits the crypto self-test before dialing out. Authentication binds
|
|
1144
|
+
its configured audience, room, broker nonce and membership digest. Current
|
|
1145
|
+
owner/epoch pins are verified before roster ACK; an owner may republish a locally
|
|
1146
|
+
newer roster after offline rotation without downgrading its pin. Non-owners never
|
|
1147
|
+
bootstrap an empty broker room. Input is binary canonical JSON, serialized with
|
|
1148
|
+
at most 8 pending packets/2 MiB per link, 1 MiB packet/backpressure limits and a
|
|
1149
|
+
30-second handshake bound. Disconnects use jittered reconnect capped at five
|
|
1150
|
+
minutes and do not block local work. Stop, leave, replacement and rotation fence
|
|
1151
|
+
all awaited work by link generation.
|
|
1152
|
+
|
|
1153
|
+
Presence is an encrypted, signed frame containing only exposed peer ids and closed
|
|
1154
|
+
idle/busy/paused/offline states. It is sent on connection and coalesced peer-state
|
|
1155
|
+
changes, never titles, bodies, private task context or credentials. Unknown payload
|
|
1156
|
+
types and unexposed ids refuse before replay commit. Broker ACKs are transport
|
|
1157
|
+
facts only. A canonical room/recipient-bound frame that is refused or stale is
|
|
1158
|
+
recorded in a durable metadata-only drop ledger before transport retirement ACK.
|
|
1159
|
+
This does not accept plaintext, advance a replay floor or assert application
|
|
1160
|
+
completion. Fixed refusal codes, digest and local timestamp are retained, capped
|
|
1161
|
+
at 1,000 diagnostics per room; eviction removes only old diagnostic rows, never
|
|
1162
|
+
cryptographic replay state. Wrong/missing session or membership authority and a
|
|
1163
|
+
failed durable record produce no ACK. Noncanonical/foreign-room packets still
|
|
1164
|
+
refuse without a transport ACK. Later valid packets are not blocked behind a
|
|
1165
|
+
retired refused packet. A failed room state or crypto initialization disables rooms and leaves
|
|
1166
|
+
local hub work available. Fake-hub/broker tests are not a live TLS/proxy or X7 receipt.
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
## Encrypted rooms: X4 screened chat and application receipts (#446)
|
|
1170
|
+
|
|
1171
|
+
This implements frozen D3/D5 from issue #446, comment 6242775004, with the
|
|
1172
|
+
approved X4 refinements and R1 clarifications. X5 metadata views are described
|
|
1173
|
+
below; X6 invocations and X7 live two-machine receipts remain separate.
|
|
1174
|
+
|
|
1175
|
+
### Egress and authority
|
|
1176
|
+
|
|
1177
|
+
Each signed-roster remote id is a host-owned `RemotePeer`, outside the native
|
|
1178
|
+
adapter catalog. Its `deliver` calls `roomBoundary` once to build the outbound
|
|
1179
|
+
view. Only explicitly addressed `chat` and `status` from an exposed local peer
|
|
1180
|
+
or the local console may cross. The boundary refuses hub/digest/remote senders,
|
|
1181
|
+
workflow kinds, private envelopes, a private sender turn, and PII or unknown task
|
|
1182
|
+
references. Private-turn/task provenance is captured at enqueue and retained
|
|
1183
|
+
while offline. An unknown authority check refuses; it never becomes a grant.
|
|
1184
|
+
|
|
1185
|
+
The screen reuses memory promotion's secrets, PII patterns and private-path
|
|
1186
|
+
rules. When the model screen is enabled, the existing device/campus-only screen
|
|
1187
|
+
owns model admission. Failure or an unknown verdict sends a fixed ids-only
|
|
1188
|
+
withheld stub. Refs are removed. Immutable host proofs recheck current roster,
|
|
1189
|
+
exposure, privacy and policy after awaits and before publication. No room payload selects callbacks, native effects or provider credentials. The
|
|
1190
|
+
host's existing fixed-model configuration owns model choice.
|
|
1191
|
+
|
|
1192
|
+
X4 accepts at most **one remote target per send**. More than one is refused before
|
|
1193
|
+
any local or remote effect, naming the targets and directing separate sends.
|
|
1194
|
+
One remote target plus local targets retains local fan-out. Multi-remote support
|
|
1195
|
+
needs target-derived stable wire ids and per-delivery cap accounting. Implicit
|
|
1196
|
+
broadcasts never include remote peers. Remote delivery has one original, no
|
|
1197
|
+
preface, no condensation and no memory observation. Incoming remote bodies are
|
|
1198
|
+
also excluded from condensation/memory; emitted events and logs contain ids and
|
|
1199
|
+
fixed stubs. Native/role/board/control/recovery actor paths refuse ids containing
|
|
1200
|
+
`@`; a remote console sender is `user@label`, never the local `user` authority.
|
|
1201
|
+
|
|
1202
|
+
The private roster binds labels to fingerprints. Ambiguous global `peer@label`
|
|
1203
|
+
addresses across rooms are inert. Queue recipient bindings survive active and
|
|
1204
|
+
uncertain journal rows; neither a restart nor a person retry may substitute a
|
|
1205
|
+
new fingerprint reusing the label. Unknown legacy bindings become uncertainty,
|
|
1206
|
+
not newly inferred identity. Native peer delivery requires a current exposed
|
|
1207
|
+
local native target. On an X4-only receiver, a reply to `user@label`
|
|
1208
|
+
receives a signed `failed_safe/person_inbox_unsupported` NACK with the fixed
|
|
1209
|
+
reason "person inbox not supported yet (X5)"; no person queue or admission is
|
|
1210
|
+
invented. X5b below adds durable person admission; X5 metadata views alone do not.
|
|
1211
|
+
|
|
1212
|
+
### Encrypted application wire
|
|
1213
|
+
|
|
1214
|
+
X1 frames carry canonical version-1 `chat` or `receipt` payloads. Chat carries
|
|
1215
|
+
id, trace, hop, local from/to ids, chat/status kind, capped priority, body and the
|
|
1216
|
+
sender-claimed timestamp. Receipt carries the exact original frame digest,
|
|
1217
|
+
application id, destination peer, closed outcome and fixed reason (including the explicit unsupported-person-inbox NACK). Unknown
|
|
1218
|
+
fields or kinds refuse. Only the expected recipient hub's authenticated receipt
|
|
1219
|
+
can settle an outgoing application. Receipt frames produce no application
|
|
1220
|
+
receipt of their own. Incoming ids are fully validated before framing, bodies
|
|
1221
|
+
use `sanitize`, priority is at most status, and sender/hub rate admission shares
|
|
1222
|
+
one atomic limiter decision.
|
|
1223
|
+
|
|
1224
|
+
The first presence on a new link session may carry the optional encrypted,
|
|
1225
|
+
signed boolean `requestReply: true`; absence means false. An accepted request
|
|
1226
|
+
elicits one ordinary presence without that request, never a reply loop. This
|
|
1227
|
+
refreshes discovery after a restart while the other hub's peers remain idle,
|
|
1228
|
+
without a periodic heartbeat. A transient disconnect retains the last signed
|
|
1229
|
+
peer states, but delivery remains gated on the current authenticated link and
|
|
1230
|
+
roster. A roster change invalidates the cached states.
|
|
1231
|
+
|
|
1232
|
+
Ciphertext publication requires the ready socket's verified roster to match the
|
|
1233
|
+
local installed roster. While a verified owner upgrades an older broker roster,
|
|
1234
|
+
one canonical same-room, own-recipient ciphertext may wait inside the existing
|
|
1235
|
+
packet/byte bounds while roster controls continue. It is not decrypted, replayed
|
|
1236
|
+
or ACKed before ready for the current roster. Generic premature or second
|
|
1237
|
+
deferred frames refuse.
|
|
1238
|
+
|
|
1239
|
+
### D5 settlement and bounded retention
|
|
1240
|
+
|
|
1241
|
+
`completed` means the remote hub durably admitted **one screened chat to its
|
|
1242
|
+
exposed local peer's queue**. It does not mean native or model execution. Person
|
|
1243
|
+
views say **completed (admitted)**. Broker `stored` means accepted ciphertext
|
|
1244
|
+
only; a transport retirement ACK is not application completion.
|
|
1245
|
+
|
|
1246
|
+
Schema3 of the private `rooms.db` migrates schema1/2 without replacing identity,
|
|
1247
|
+
pins, counters, floors or retained traffic. A receiver commits its crypto inbox,
|
|
1248
|
+
then application `pending -> dispatching` before publishing into the separate
|
|
1249
|
+
Bus journal. A crash or failure in that cross-DB publication window becomes
|
|
1250
|
+
`needs_review`, with no automatic republish or fabricated receipt. Sender
|
|
1251
|
+
`dispatching`/`accepted` rows become `needs_review` at restart. A same-process
|
|
1252
|
+
reconnect drains never-handed offline queues once. A late signed receipt is kept
|
|
1253
|
+
as evidence on `needs_review` and shown with the person's disposition view; it
|
|
1254
|
+
never auto-settles. Known safe refusals retain the Bus's existing poison policy.
|
|
1255
|
+
|
|
1256
|
+
Signed seq/floors are the permanent primary replay protection. Duplicate seqs
|
|
1257
|
+
are retired without a second admission; a known identical completed application
|
|
1258
|
+
can resend its saved outcome. The extra `(sender, env.id) -> content hash` guard
|
|
1259
|
+
refuses changed content while retained. Identical uncertain duplicates never
|
|
1260
|
+
republish. Tombstones retain 30 days, at most 10,000 per room; at capacity the
|
|
1261
|
+
oldest settled rows are evicted first. Pending, dispatching and unresolved
|
|
1262
|
+
`needs_review` are never evicted. An all-unsettled cap refuses with the room and
|
|
1263
|
+
count. Outside retention or after settled eviction, there is no historical
|
|
1264
|
+
app-id dedupe claim; an old signed frame is still refused by its seq floor.
|
|
1265
|
+
|
|
1266
|
+
The trace high-water mark is kept for the tombstone retention (30 days / 10,000
|
|
1267
|
+
per room, settled rows evicted first); outside it there is no historical trace
|
|
1268
|
+
claim, and MAX_HOP 3 still bounds every trace. Before local admission a hop below
|
|
1269
|
+
the retained maximum for that trace refuses; equal or higher hops remain allowed.
|
|
1270
|
+
There is no second trace ledger. Sender timestamps do not order messages or
|
|
1271
|
+
supply host expiry provenance; gaps are accepted and reported as signed frame
|
|
1272
|
+
sequence counts, never waited on. They do not prove which chat bodies were lost.
|
|
1273
|
+
|
|
1274
|
+
Offline Bus queues count toward the 200-per-room ceiling before transport starts;
|
|
1275
|
+
`hub_send` refuses beyond it. Original host deadlines survive handoff and restart.
|
|
1276
|
+
Unknown legacy deadlines fail closed to `needs_review`. Machine-local
|
|
1277
|
+
`rooms.ack_timeout_s` defaults to 600 (1..86,400); `rooms.queue_ttl_h` defaults to
|
|
1278
|
+
24 (1..168). A single deadline-driven host timer handles ack/queue expiry. No
|
|
1279
|
+
uncertain work is silently retried. Explicit terminal-history retries without
|
|
1280
|
+
recipient proof refuse; the person can author a fresh message deliberately.
|
|
1281
|
+
An expired or missing original enqueue deadline also refuses a person retry
|
|
1282
|
+
before any disposition is recorded; a retry never invents a fresh TTL.
|
|
1283
|
+
|
|
1284
|
+
The ACK deadline starts at durable application handoff, before a broker `stored`
|
|
1285
|
+
reply, and is never extended by that reply. An unstored frame therefore becomes
|
|
1286
|
+
`needs_review` within the same ACK timeout instead of holding the peer for the
|
|
1287
|
+
queue TTL. Known pre-admission capacity or journal-unavailable refusals retain
|
|
1288
|
+
their pending plaintext for the next online/disposition retry without preventing
|
|
1289
|
+
later room packets and receipts from being processed. Status reads apply no
|
|
1290
|
+
disposition. Settled chat plaintext rows are pruned independently of presence
|
|
1291
|
+
traffic; permanent floors and retained application outcomes survive that pruning.
|
|
1292
|
+
|
|
1293
|
+
The crypto inbox has a separate durable reserve of at most eight receipt rows
|
|
1294
|
+
per room, each canonical receipt payload at most 1,024 bytes, with at most
|
|
1295
|
+
64 * 8 * 1,024 additional bytes globally. Before taking a reserved slot, the
|
|
1296
|
+
authenticated sender and receipt frame/application/peer must match this hub's
|
|
1297
|
+
outstanding `dispatching`, `accepted` or `needs_review` outgoing row. Junk or
|
|
1298
|
+
non-matching receipts cannot use the reserve. Normal chat remains bounded to
|
|
1299
|
+
200 pending rows and 16 MiB; presence caps, signatures and floors do not change.
|
|
1300
|
+
A matched receipt settles and frees its slot immediately. The durable
|
|
1301
|
+
`receipt_reserve` marker preserves its admission across a crash after settlement;
|
|
1302
|
+
open validates the canonical payload, size and reserve bounds and refuses an
|
|
1303
|
+
over-cap store as corrupt. Migration marks every legacy row ordinary and never
|
|
1304
|
+
infers reserved authority from its JSON.
|
|
1305
|
+
At the physical cap only, an authenticated current-member receipt with no
|
|
1306
|
+
matching outstanding row is durably recorded as the existing metadata-only
|
|
1307
|
+
`invalid_frame` refusal and transport-ACKed, without advancing the floor or
|
|
1308
|
+
sending a signed receipt. Below the cap its normal handling is unchanged.
|
|
1309
|
+
A matching receipt with all eight reserved slots occupied remains unACKed until
|
|
1310
|
+
a slot frees; it is never retired as junk.
|
|
1311
|
+
|
|
1312
|
+
At the ordinary physical cap, a fresh fully authenticated chat frame is durably
|
|
1313
|
+
refused with metadata-only `invalid_frame` and transport-ACKed, without a floor
|
|
1314
|
+
advance, admission or signed receipt. Its plaintext is not retained in the full
|
|
1315
|
+
inbox. The sender's application stays unsettled and expires to `needs_review`;
|
|
1316
|
+
matching receipts behind the refused chat can still reach the reserve. Known
|
|
1317
|
+
exact-duplicate and stale sequence handling precede this fresh-frame cap check.
|
|
1318
|
+
|
|
1319
|
+
At the ordinary physical cap, a fully validated signed current-member presence
|
|
1320
|
+
is recorded as metadata-only `invalid_presence` and transport-ACKed. It consumes
|
|
1321
|
+
no reserve slot, advances no floor and produces no `onPresence` observation.
|
|
1322
|
+
Runtime and other store errors remain unACKed. After room/global capacity has
|
|
1323
|
+
space for the retired presence again, one coalesced `requestReply` presence
|
|
1324
|
+
refetches the current remote state per drop-below-cap transition. There is no
|
|
1325
|
+
periodic capacity poll or permanent reliance on a dropped state observation.
|
|
1326
|
+
|
|
1327
|
+
### Person disposition
|
|
1328
|
+
|
|
1329
|
+
`ahub room resolve <name> --frame <digest> --action accept|discard` uses the
|
|
1330
|
+
existing console-only preview ticket and fingerprint confirmation. Agent markers
|
|
1331
|
+
and `--live-test` refuse it. It records only local `disposed` metadata: the
|
|
1332
|
+
person's action, time and actor. Accept means the person vouches that the local
|
|
1333
|
+
peer got the chat; discard means drop it. Neither re-admits or republishes.
|
|
1334
|
+
Views say **disposed (accept|discard, by person)**, never completed. A disposed
|
|
1335
|
+
row is settled for retention/cap eviction, but never creates or resends a signed
|
|
1336
|
+
completed/failed_safe receipt. A retained duplicate remains non-publishable.
|
|
1337
|
+
Outgoing person queue dispositions likewise do not manufacture remote proof;
|
|
1338
|
+
late signed proof remains evidence-only.
|
|
1339
|
+
|
|
1340
|
+
Explicit room status is metadata-only and bounded: up to 200 outgoing rows
|
|
1341
|
+
prioritizing unresolved traffic, up to 200 incoming rows with uncertainty first,
|
|
1342
|
+
counts/truncation and remote addresses/states. Native turns, task roles and
|
|
1343
|
+
recovery sessions are never invented for remote transports. Real TLS/proxy,
|
|
1344
|
+
account and two-machine X7 proof remain unverified until their own live legs.
|
|
1345
|
+
|
|
1346
|
+
## Encrypted rooms: X5 metadata views (#446)
|
|
1347
|
+
|
|
1348
|
+
The console's Rooms panel (`8`) and the dashboard's Agents > Rooms section are
|
|
1349
|
+
read-only views of the same canonical room metadata: local room name/id, epoch,
|
|
1350
|
+
owner/self fingerprints, member count, link state and exposed remote peer
|
|
1351
|
+
ids/states. Missing room metadata from an older hub and an empty room list hide
|
|
1352
|
+
the views; unavailable metadata is explicit rather than retaining old readings.
|
|
1353
|
+
Status reads create no room storage or keys.
|
|
1354
|
+
|
|
1355
|
+
Relay counters describe only this daemon session, starting at daemon startup and
|
|
1356
|
+
resetting on restart. Outbound counts successful handoffs; inbound counts
|
|
1357
|
+
confirmed local admissions. Withheld counts are a subset of outbound handoffs,
|
|
1358
|
+
not an additional total. Missing or unreadable values stay `null`/unknown, never
|
|
1359
|
+
zero. These counters do not prove application completion or lifetime traffic.
|
|
1360
|
+
|
|
1361
|
+
The projection contains no message body/title, reason, broker URL, key material
|
|
1362
|
+
or delivery history. This projection never reads a person inbox body; X5b below
|
|
1363
|
+
provides that separate person-only CLI. Native/account and X7 two-machine
|
|
1364
|
+
receipts remain separate live verification.
|
|
1365
|
+
|
|
1366
|
+
## Encrypted rooms: X5b person inbox (#446)
|
|
1367
|
+
|
|
1368
|
+
Authenticated eligible chat to local `user` is admitted to a durable person
|
|
1369
|
+
inbox, separately from native peer delivery. The signed
|
|
1370
|
+
`completed/inbox_admitted` receipt, displayed as **completed (inbox)**, means
|
|
1371
|
+
durable inbox admission only. It does not mean the person read or acted on it.
|
|
1372
|
+
Admission, duplicate identity and receipt commit together before publication;
|
|
1373
|
+
duplicate frames cannot admit or count the message again. Done, discard and
|
|
1374
|
+
expiry never change that receipt or manufacture another outcome.
|
|
1375
|
+
|
|
1376
|
+
The RPC gate admits only the console role. As with X3 room mutations, the CLI
|
|
1377
|
+
refuses agents, conductors and `--live-test` before connecting. A console RPC
|
|
1378
|
+
connection carries no delegated/live-test marker, so that flag is not independently
|
|
1379
|
+
classified at the RPC gate. Dashboard, status, logs, memory, models and observers
|
|
1380
|
+
receive no inbox body:
|
|
1381
|
+
|
|
1382
|
+
```sh
|
|
1383
|
+
ahub room inbox [<name>] [--json]
|
|
1384
|
+
ahub room inbox show <id>
|
|
1385
|
+
ahub room inbox done <id>
|
|
1386
|
+
ahub room inbox discard <id>
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
One operand always names a room, including `show`, `done` and `discard`; only
|
|
1390
|
+
an action followed by an exact local inbox UUID performs that action. Only the
|
|
1391
|
+
metadata list accepts `--json`; none of these forms accepts `--confirm` or adds a
|
|
1392
|
+
fingerprint prompt. Show returns one untrusted framed body through `sanitize`
|
|
1393
|
+
and terminal control stripping, and never marks it done. Done/discard are local
|
|
1394
|
+
metadata changes and immediately purge the body. Expired or disposed entries
|
|
1395
|
+
show metadata stubs only: no archive or read-after-done path exists.
|
|
1396
|
+
|
|
1397
|
+
A person done/discard commits and replies before firing a background retry, so
|
|
1398
|
+
a retry failure or socket drop during that work cannot change its acknowledgement.
|
|
1399
|
+
It and an actual TTL purge create one capacity-release opportunity for existing
|
|
1400
|
+
X4 deferred traffic. The disposed or expired entry gets
|
|
1401
|
+
no new outcome or receipt; unrelated authenticated pending chats may resume.
|
|
1402
|
+
Idle expiry ticks never retry or poll retained plaintext.
|
|
1403
|
+
Cleanup performed during show or admission retains a committed purge signal until
|
|
1404
|
+
the host consumes it once; rollback restores the prior signal.
|
|
1405
|
+
|
|
1406
|
+
Limits are 100 unread messages per room, 64 KiB decoded body per message and
|
|
1407
|
+
4 MiB retained canonical payload bytes globally (the stored BLOB length, including
|
|
1408
|
+
JSON envelope overhead and escaping). Metadata `bytes` remains decoded UTF-8 body
|
|
1409
|
+
size. The existing 64 KiB entire
|
|
1410
|
+
application-wire cap is unchanged, so envelope overhead makes the actual
|
|
1411
|
+
sendable body smaller. Schema 4 records the first durable local crypto arrival;
|
|
1412
|
+
the inbox TTL is 30 days from that time, not the remote timestamp, admission
|
|
1413
|
+
retry or restart. Legacy pending person traffic with unknown local arrival age
|
|
1414
|
+
receives fixed `failed_safe/inbox_age_unavailable`; native peer handling is
|
|
1415
|
+
unchanged. Other fixed refusal reasons are `inbox_full`, `inbox_oversize` and
|
|
1416
|
+
`inbox_expired`.
|
|
1417
|
+
|
|
1418
|
+
Retain up to 1,000 settled metadata stubs per room for 30 days after settlement,
|
|
1419
|
+
evicting oldest settled stubs first. Unread messages and their matching admission
|
|
1420
|
+
identities remain protected, as do unsettled identities under the shared
|
|
1421
|
+
10,000-per-room application cap. Inbound session counts increment once at
|
|
1422
|
+
admission, never on duplicate delivery, show or disposition. One 30-second
|
|
1423
|
+
loaded-state daemon hook expires bodies even when all rooms were left; it opens
|
|
1424
|
+
no empty store and creates no keys.
|
|
1425
|
+
|
|
1426
|
+
Lists return complete metadata, with at most 1,100 rows (100 unread plus 1,000
|
|
1427
|
+
stubs for one filtered room). A larger global list explicitly refuses with
|
|
1428
|
+
`inbox_list_limit` and guidance to choose a room, never silently truncates.
|
|
1429
|
+
ponytail: this complete-list ceiling keeps the first slice bounded; pagination
|
|
1430
|
+
is the follow-up for cross-room inventories. Inbox reads and dispositions refuse
|
|
1431
|
+
during recovery or a Bus hold in this slice.
|
|
1432
|
+
|
|
1433
|
+
Bodies are plaintext in the private local store under its existing directory/file
|
|
1434
|
+
modes; this adds neither at-rest encryption nor secure-erasure guarantees.
|
|
1435
|
+
Older X4 codecs reject the new receipt reasons and leave their sender outcome
|
|
1436
|
+
uncertain; no mixed-version success is claimed. Native/account and live
|
|
1437
|
+
two-machine receipts remain separate verification.
|
|
1438
|
+
|
|
718
1439
|
## Risks
|
|
719
1440
|
|
|
720
1441
|
| Risk | Mitigation |
|
|
@@ -1570,6 +2291,30 @@ Release 0.12.16 uses control protocol 14. Recovery sources 9 through 13 remain
|
|
|
1570
2291
|
supported; protocol 13 identifies releases 0.12.4 through 0.12.15, while
|
|
1571
2292
|
0.12.16 uses protocol 14.
|
|
1572
2293
|
|
|
2294
|
+
## New-work context admission (issue #802)
|
|
2295
|
+
|
|
2296
|
+
`context.max_assignment_used` defaults to 0 (disabled), is a machine-local
|
|
2297
|
+
finite fraction in `[0, 1]`, and is independent of `context.gate`. Only an
|
|
2298
|
+
untracked local source and a confirmed person reload (or deliberate restart)
|
|
2299
|
+
may enable it. A known occupancy greater than or equal to the configured
|
|
2300
|
+
nonzero threshold excludes a candidate from a new automatic owner offer.
|
|
2301
|
+
|
|
2302
|
+
`assign()` remains pure: its host inputs include the threshold, freshness
|
|
2303
|
+
limit, clock, attachment, current native session and a copy of that peer's
|
|
2304
|
+
native reading. Validate timestamp, session equality and finite occupancy
|
|
2305
|
+
before comparing. Missing, invalid, stale, future, mismatched or unsupported
|
|
2306
|
+
readings are unknown, never zero or evidence of headroom. Unknown does not add
|
|
2307
|
+
an eligibility refusal; the ordinary gates still apply. Traces show the same
|
|
2308
|
+
context decision as actual assignment without exposing session identifiers.
|
|
2309
|
+
|
|
2310
|
+
Only new automatic owner offers use the guard; reviewer admission is unchanged,
|
|
2311
|
+
including owners of review-class work. A name or reservation is not an override.
|
|
2312
|
+
A host-authorized explicit person/conductor nomination or self-claim can bypass
|
|
2313
|
+
only this context guard; all other privacy, roster, quota, capability and manual
|
|
2314
|
+
assignment constraints remain. Re-evaluate at the existing post-await owner
|
|
2315
|
+
admission boundary. No reading triggers pause, compaction, replacement, task
|
|
2316
|
+
reassignment or a new retry timer. Checkpoint pressure semantics stay unchanged.
|
|
2317
|
+
|
|
1573
2318
|
## Operator console and conductor (issues #190, #191, #193, #194, #195)
|
|
1574
2319
|
|
|
1575
2320
|
`ahub console` owns one authenticated console connection. It shares tail's
|
|
@@ -2414,7 +3159,7 @@ acts on the stopped state directory.
|
|
|
2414
3159
|
- Recording never blocks or fails the task flow: the writer runs after the board event, and a failure is one
|
|
2415
3160
|
hub.log line per run. ponytail: it reads the whole events file for each record; an incremental per-task index
|
|
2416
3161
|
if that file grows large.
|
|
2417
|
-
- Agents may read the measures and export
|
|
3162
|
+
- Agents may read the measures and export. `research backfill` is the person's or the configured conductor's, and an agent labels only its own task, where a person's label always wins (#933 amendment; identity gate).
|
|
2418
3163
|
|
|
2419
3164
|
## Amendment: proven Pi tool refusals and idle exit recovery (issues #254, #255)
|
|
2420
3165
|
|
|
@@ -2446,7 +3191,7 @@ acts on the stopped state directory.
|
|
|
2446
3191
|
ponytail: a board state to withdraw an open task would let the run continue. The fingerprint holds what the hub's
|
|
2447
3192
|
status reports (a requested model for Pi, permission modes once #242 lands); ponytail: read each peer's launch
|
|
2448
3193
|
settings for the rest.
|
|
2449
|
-
- The dashboard snapshot carries run summaries and per-arm measures only. `bench run` is
|
|
3194
|
+
- The dashboard snapshot carries run summaries and per-arm measures only. `bench run` is the person's or the configured conductor's (#933 amendment; identity gate,
|
|
2450
3195
|
wherever `--json` stands); reading results is not. Schema and commands: [bench](../bench.md).
|
|
2451
3196
|
|
|
2452
3197
|
|
|
@@ -2750,6 +3495,7 @@ the queue.
|
|
|
2750
3495
|
- Schemas, CLI classification, Tasks, daemon/native contracts, plugin instructions, both local-worker tool-building paths, check-name allowlists and init use the same resolved data. No-override dev contracts/schemas/instructions remain byte-identical. Old accept schemas support existing task fields.
|
|
2751
3496
|
- Flow authority remains the separately validated `roles` map; executable checks remain trusted `checks`. Example checks are display data and do not execute. Managed instruction content keeps surrounding text and quotes forged headers.
|
|
2752
3497
|
- Acceptance criteria with ids (#507b) reuse the #481 S8 `criteria` checklist, the only criteria model: `sendReview` lists the recorded criteria (`- <id>: <result>`, `(optional)` for a non-required one) ahead of the profile's checklist lines in every profile, and `Tasks.review` records on the verdict the ids that the reviewer's `unmet` items name, through the pure `unmetCriteriaIds` (an item names a criterion only by starting with its exact recorded id, followed by the end of the item or a delimiter; each id once, in first-named order). The ids come from the board's checklist at the verdict, never from the caller, and are stored as `HistoryEntry.unmetCriteria` (validated: at most 32 distinct bounded ids, on a saved review verdict only) and shown in the public history. The note text is unchanged. A PII task lists and records none; a task with no checklist, a verdict with no unmet items and an item with no matching id add nothing, so every existing request and verdict is byte-identical. #513 packing and #514 learning refer to these ids.
|
|
3498
|
+
- Recurring unmet criteria (#514a, `src/hub/learning.ts`, `Tasks.proposeCriterionCandidates`) reuse the #481 S3 recurrence path: after a saved verdict that named criteria, the pure `groupUnmetCriteria` groups the ids reviews recorded as `unmetCriteria` in the last 30 days by profile, profile class (the alias when the task has one) and criterion, over rows that are not PII and whose text the candidate screen allows. A group that this verdict named, that has at least 3 tasks forming at least 2 separate pieces of work (`workUnits`) and no card yet (`Criterion candidate key: <profile>:<class>:<id>` in a hub-filed backlog card's detail, the same dedupe as `Review finding key:`) files ONE backlog `plan` card for a person: ids and counts only, plus the `profile_overrides.review_checklist` fragment as text (`overridesDiff`). Nothing is applied: `profile_overrides` is not machine-local (it is read from the tracked `.agenthub/config.json` and the untracked `config.local.json`, layers merged, and is in neither `MACHINE_LOCAL` list), so the person chooses the layer and merges the line into their own checklist, which replaces as a unit. `selectGroups` applies those filters before the per-pass cap of 64 cards, so groups that are settled (already carded, unnamed, under threshold) never use up the cap and starve a new one. A withheld verdict, an evidence-only verdict (#852), a private turn and a PII task file nothing and count for nothing. Per-profile `profile_overrides` application, thresholds as config and decay are not part of this step.
|
|
2753
3499
|
- Override maps are bounded to 32 class entries and existing base classes; plan selections use existing fields. Role text is 2000 characters, agents 16000, examples 16 strings of 2000 characters, review checklist 16 lines of 500 characters (#507). Native ingestion/retention/measurement remains unverified; configuration changes apply after daemon/MCP restart.
|
|
2754
3500
|
|
|
2755
3501
|
|
|
@@ -4145,10 +4891,10 @@ These rules are settled here and land with hub tasks #284-#286; until then the S
|
|
|
4145
4891
|
|
|
4146
4892
|
- `ahub sdlc metrics [--since]` and the planner tool `hub_sdlc_metrics` are a pure projection of board history (`src/hub/sdlc-metrics.ts`: no clock, no I/O, no model). `Tasks.sdlcMetrics` is the one read, for a planner or the person, refused during a private turn; private tasks are counted as withheld and read for nothing else. Anything the board cannot derive is `null`/`unknown` and counted, never 0.
|
|
4147
4893
|
- Scope cuts, each deliberate:
|
|
4148
|
-
- **
|
|
4894
|
+
- **PR readback is the merge's, not the report's (#442).** The report still reads only the board. The guarded merge reads the merged PR once, after it observes the merge, and records `cost: { mergedAt, ciRuns, ciReruns, reviews }` on the task's merge record (`readPrCostHistory` under an 8 s bound, host-written, never a tool input, never part of the merge decision). The report then reads those recorded figures, so it keeps the board as its only source and makes no GitHub call: a second reader of the same surface would duplicate `ahub sdlc costs`' bounds and privacy checks, which this does not. A task whose merge was not read stays `unknown`, never 0. The cut that remains: a PR merged by hand or before this record has no figures.
|
|
4149
4895
|
- **S10's `costBandProposals` is not consumed.** It returns per-PR upper-limit breaches over complete merged-PR rows, a different data set (review rounds, CI runs and reruns per merged PR) from board-history metrics, and it takes absolute limits rather than a rolling baseline. S10 left the baseline, configuration and automatic card scheduler to S7 and delivered the pure breach data only; S7 builds the scheduler for its own metrics and leaves the PR cost metrics' bands for the slice that also reads PRs, so one configuration and one dedupe rule cover both.
|
|
4150
|
-
- **
|
|
4151
|
-
- **
|
|
4896
|
+
- **Security-scan baseline is an opt-in read of GitHub's own alerts (#442).** No scan source existed in the repository, and inventing a scanner would claim a control nothing enforces, so the hub runs none. `[security_scan]` (no default source) names which of Dependabot, secret scanning and code scanning to read; the daemon reads their open-alert counts on the configured interval, records counts only in `security_scans`, and `security_open_alerts` is a band over the totals, so a breach is the existing one-card-per-episode path. A source GitHub will not read makes the total unknown and unknown is not a point, never a zero. The cut that remains: no scan runs unless the person configures one, and a repository that exposes no alert source records nothing.
|
|
4897
|
+
- **Release metrics and the `hotfix_rate` band are built; the gates-run record is not (#442).** The release report shows release interval, lead time from final approval to release and hotfix rate from `releaseMetrics`, with unknown named and never 0, and `hotfix_rate` is a band with one point per shipped release that has recorded train history (a legacy release is left out, never a 0). The per-release gates-run record stays S11's explicit deferral: a cut record is not gate-execution evidence, and recording gates needs the S6 authorization path.
|
|
4152
4898
|
- **`first_pass_check` is the hub's completion check, not PR CI.** It reads `check passed` and `check failed` at `hub_task_done`, so it is `unknown` wherever completion checks are off (the default). The issue's "first-pass CI success" is the PR-based figure and is not available without the readback above.
|
|
4153
4899
|
- **Stale-head re-reviews need head evidence.** A review round is stale when its verdict is bound to a different head than its request (`reviewCost.head` on the done against `reviewFindings.head` on the verdict). A task without both is `unknown`. A `done` after an `approved` is not a signal: the board's move table has no way out of `approved`.
|
|
4154
4900
|
- Control bands are optional `[bands.<metric>]` tables of `sdlc.toml`, validated by `bandsConfig` with field-level paths. The newest value is compared with the mean and population standard deviation of the preceding `window` values, on the bad side only, with a per-metric deviation floor, a `min_samples` floor, a `min_delta` floor that every tier including the drift rule honours, and a drift rule (`drift_run` newest values all over one deviation on the bad side is at least `diagnose`).
|
|
@@ -4156,6 +4902,14 @@ These rules are settled here and land with hub tasks #284-#286; until then the S
|
|
|
4156
4902
|
- A breach is an episode: the run of non-ok values for one metric. `BandResult.episode` is the task that started the run, derived from history alone, so a restart finds the same key. `Tasks.proposeFromBands` is the only writer. `log` is one hub notice, recorded in the board's `band_notices` table so it is announced once. `diagnose` is one read-only `triage` backlog task and `propose` one `plan` backlog card, keyed `<metric>:#<episode>:<tier>` in the card. A metric with an open band card writes nothing more, so a value growing from diagnose to propose inside one episode adds no second card, and a sustained shift is one card. Card text is fixed words, numbers and task ids, screened like the recurring-finding card; the hub opens no GitHub issue.
|
|
4157
4903
|
- The daemon evaluates every five minutes, only when the board's status revision or the band configuration moved since the last evaluation that ran, and not while it is stopping, in recovery or has a storage error (the same holds as the task sweep). A pass that was held, in mode off or without bands consumes nothing, so a breach seen then is written when the hold clears and bands enabled by a reload on an idle board are evaluated at the next tick. A failed evaluation is one `hub.log` line. Ceiling: one evaluation reads every task with its history; a history window is the upgrade if a board passes several thousand tasks.
|
|
4158
4904
|
- Bands write cards only in `advise` or `enforce`; in `off` the writer is idle while the report still shows each configured band's tier.
|
|
4905
|
+
- Two bands are not made of tasks (#442): `hotfix_rate` (a point per shipped release) and `security_open_alerts` (a point per recorded scan). `BandResult.subject` says what `lastTask` and `episode` number (`release` or `scan`), the card text names that subject (`release #N`, `security scan #N`) and the key format `<metric>:#<id>:<tier>`, the open-card rule and the episode derivation are unchanged. The band signature that gates the five-minute evaluation includes the shipped-release count and the newest scan id, since neither moves the task board's revision.
|
|
4906
|
+
|
|
4907
|
+
### S5 eval gate amendment (#481, hub task #201)
|
|
4908
|
+
|
|
4909
|
+
- Scope: the gate mechanism only. The eval suite and its smoke subset (#266, #251) do not exist, and choosing one is a person decision, so the hub chooses, creates and defaults to none: `eval_check` has no default and an unconfigured gate reports `eval check not configured: ... (suite choice pending the person)` as a note in advise and enforce, never a silent pass and never a blocker, so an enforce setup gains no refusal until the person names a suite.
|
|
4910
|
+
- `config_paths` (default AGENTS.md, `docs/agent-notes/**`, `templates/**`, `.agenthub/roles/**`, routing.toml and `src/hub/hub-tools.ts`) marks a configuration PR from complete PR file metadata (`src/hub/eval-gate.ts`). An incomplete inventory is unknown, never a code-only PR. A code-only PR is unaffected and off keeps its output byte for byte.
|
|
4911
|
+
- The configured `eval_check` joins the required checks wherever they are judged (`sdlcStatus`, the queue and `decideMerge`, each reading). The pass rate and suite version come from one exact-head completed check run of that name, parsed only from the `eval_pass_rate=` and `eval_suite=` markers in its title and summary, and are recorded on the approval note. With a check named, gaps (unknown coverage, a pass with no reported rate or suite) are shown in advise and block in enforce.
|
|
4912
|
+
- Cuts: no suite, no case from an S7 incident, no tag or release gating, and no per-repository `eval_check` table (a repository takes the top-level key). The marker contract is the hub's, not any suite's; the person's chosen suite must emit it, or the gap stays visible.
|
|
4159
4913
|
|
|
4160
4914
|
## Antigravity optional implementer peer (#485 P1)
|
|
4161
4915
|
|
|
@@ -4339,7 +5093,7 @@ The owner gave the #516 track its go-ahead on 2026-10-22. This section closes th
|
|
|
4339
5093
|
|
|
4340
5094
|
### Sources (#508)
|
|
4341
5095
|
|
|
4342
|
-
- `ahub source add <path|url>` is person
|
|
5096
|
+
- `ahub source add <path|url>` is the person's or, since #933, the configured conductor's (admitted and audited by the hub); other agent shells are refused like the other console commands. The id is `src:` plus the first 16 hex digits of the snapshot's SHA-256, so adding the same bytes twice returns the existing id.
|
|
4343
5097
|
- The snapshot lives in the hub state directory under `sources/<id>/`: `meta.json` (`{id, kind, origin, sha256, bytes, added_at, private}`), the raw bytes, and the projected text. The raw cap is the text-projection input cap (`MAX_PROJECTION_INPUT`, 16 MiB), and the text comes from the existing text_projection converters with their caps and sandbox. Larger inputs are refused with the cap in the reason; nothing is truncated silently.
|
|
4344
5098
|
- A URL is fetched once, by the person's CLI process, over https only, following at most 5 redirects, within the same 16 MiB cap. No agent, sandbox or hub tool fetches a URL. Because the person issues the fetch, a PII project does not refuse it; the snapshot is screened like any file.
|
|
4345
5099
|
- A path under `pii.paths`, or a snapshot whose text the PII screen flags, is stored with `private: true`.
|
|
@@ -4347,6 +5101,28 @@ The owner gave the #516 track its go-ahead on 2026-10-22. This section closes th
|
|
|
4347
5101
|
- `hub_source_read {id, range?}` takes `range: {offset, length}` in characters but bounds the response in bytes: after `sanitize()` and the PII screen, the text is at most 32 KiB of UTF-8, cut at a character boundary with an explicit cut marker, and the reply returns the `next` character offset. It is excluded from capture.
|
|
4348
5102
|
- The plan's `sources` field is validated against the registry: an unknown id is refused at propose, accept and plan. Overlap notices name registry ids, never origins.
|
|
4349
5103
|
|
|
5104
|
+
#### Sources reader implementation (#508 AC3, hub task #333)
|
|
5105
|
+
|
|
5106
|
+
- `hub_source_read` is a read tool on the common task-tool surface. Offsets and lengths count Unicode code points, starting at zero; both range fields are required when a range is supplied. Length is positive, offset nonnegative, and both are safe integers. The reply contains `id`, `kind`, `private`, `text` and `next`; it never includes an origin. A withheld private reply contains only `id`, `kind` and `private: true`.
|
|
5107
|
+
- The reader screens the whole stored projection before selecting a range. It reapplies current `pii.paths` to every file origin and current `pii_patterns` to the complete text. With local model screening enabled under `local_only`, it uses the existing bounded host PII screen. A timeout, unavailable model, invalid pattern or oversized input is private. The existing 6000-byte model-screen ceiling applies to the full source; this slice does not classify excerpts as a substitute for the full source.
|
|
5108
|
+
- Host classification upgrades `meta.json` under the registry's registration lock without adding an origin, so the origin cap cannot prevent an upgrade. A public registration can never undo that classification. A screen failure is withheld, never exposed as a clear result.
|
|
5109
|
+
- Private text requires the daemon's in-process builtin on-prem worker, a current PII task turn, and a positive on-campus check. A control WebSocket claiming the local peer is insufficient. Authority, policy and record changes across awaited screens or campus checks fail closed. Already-private text is not sent to the model screen.
|
|
5110
|
+
- Pagination sanitizes the chosen original characters, includes an explicit cut marker when characters remain, and caps that text including the marker at 32 KiB of UTF-8. `next` counts consumed original characters, so sanitation expansion cannot skip text. Tool calls and their results are excluded from local and native memory capture; pending-delivery hints are separate from the source JSON.
|
|
5111
|
+
|
|
5112
|
+
#### Sources (#508) implementation notes (hub task #329: `ahub source add|list`, `src/hub/sources.ts`)
|
|
5113
|
+
|
|
5114
|
+
Where the code differs from or fixes a detail the section above leaves open:
|
|
5115
|
+
|
|
5116
|
+
- **Directory name.** The snapshot directory is `sources/src-<16 hex>/`, not `sources/src:<16 hex>/`: a colon is not a safe path segment on every filesystem. The id stays `src:<16 hex>` everywhere it is shown, stored in `meta.json` and accepted as input; only the folder drops the colon. A reader maps an id to its folder with `id.replace(":", "-")` and refuses anything that is not exactly `src:` plus 16 lowercase hex digits, so an id can never name a path.
|
|
5117
|
+
- **`meta.json` shape.** The spec's `{id, kind, origin, sha256, bytes, added_at, private}` describes the first registration and is kept exactly. `origins` is added: every registration of those bytes (`{kind, origin, added_at, private}`, at most 256, an exact repeat adds nothing) so "keeps every origin" is data, not a promise. A distinct origin beyond 256 is explicitly refused before any mutation, including a proposed private upgrade; that attempted registration did not occur, and the existing record stays unchanged. Exact repeats remain idempotent at capacity. `private` is the OR over `origins`; a `meta.json` whose flag is lower than one of its origins is damaged and reads as no record, so a hand edit or a stale writer can never downgrade a source.
|
|
5118
|
+
- **What "the PII screen" is on this path.** `ahub source add` runs in the person's CLI process, which has no daemon and no model gateway, so it applies the deterministic screens only: `pii.paths` for a file, and `signals.pii_patterns` (the same regular expressions the hub applies to task text) to the snapshot's text. The model screen (`signals.pii_screen = "local"`) needs the daemon's on-campus check and is not run here. A pattern that cannot be evaluated counts as flagged (`text could not be screened`), never as clear. A snapshot only the model screen would flag registers public in the CLI; the daemon-side reader (`hub_source_read`, AC3, implemented in task #333) screens it again before exposure and upgrades the record, which the monotonic rule allows. The reasons are fixed words (`path under pii.paths`, `text matches signals.pii_patterns`, `text could not be screened`); the matched text is never printed.
|
|
5119
|
+
- **Text.** The text is the existing converters' projection (`TextProjections.projectBytes`, with their caps and sandbox) where one handles the extension; otherwise a plain file that is valid UTF-8 without NUL bytes is its own text, and anything else has no `text` file and is not guessed at. A converter that fails leaves the plain-text fallback, never an unscreened empty record.
|
|
5120
|
+
- **URL.** Redirects are followed by hand so each hop is re-checked as `https:` without credentials; a relative `Location` resolves against the current URL; the sixth redirect is refused without a seventh request. The body is read through a running byte cap, so a response that lies about or omits its length is cut off at 16 MiB and never buffered whole, and a declared `content-length` over the cap is refused before the body is read. The registered origin is the final URL.
|
|
5121
|
+
- **Authority.** `source list` is `console` in `classifyPeerCommand` by falling through (an unlisted command is the person's), and `source add` is `conductor` (#933: see the amendment "the console class keeps trust, money, attribution and terminals"); a test pins every form, and `cliCommandLabel` names `source add` or `source list` in the audit and in a refusal, never the path or URL. Both registration and listing also refuse `--live-test` delegation under agent markers, through the existing person-attributed-content gate, before any source is read, fetched or written.
|
|
5122
|
+
- **One registry lock, and caps that match.** Registrations are serialized across processes by `sources/.lock`, published whole by `link(2)` of a temp file that already holds the owner's pid and process signature, so it never exists without an owner. Only its own holder removes it, when the registration ends. It is never taken over: a lock whose owner is proven gone makes the registration fail closed, naming the file for the person to delete, because "check, then unlink" is not atomic and two recoverers could each delete the other's fresh lock and mutate together. A stale-holder refusal rechecks the same inode and owner bytes after the process inspection: a normally released or replaced lock retries within the existing deadline instead of reporting an old observation as stale. No contender removes any lock. A registration that cannot get a live holder's lock within ten seconds fails naming the lock. Reader and writer share one cap per file (`MAX_META_BYTES` 2 MiB, `MAX_SOURCE_TEXT` equal to the raw cap), so what `add` accepts can always be read back. A directory with a `meta.json` that cannot be read is damaged, not absent: `add` refuses it and leaves it as it is, because rebuilding it could drop a private flag.
|
|
5123
|
+
- **Registry capacity and enumeration.** The 10,000-source cap counts valid metadata records, never lock files, contender temporary files, crash partials or damaged entries. Lists validate records before applying the cap; an already overfull on-disk registry refuses explicitly instead of silently cutting rows. New registrations enforce capacity under the registry lock, including when their target directory is a crash partial. Existing-source privacy/provenance updates remain possible at source capacity, subject to the separate origin cap.
|
|
5124
|
+
- **Not in the CLI slice.** The reader is implemented separately in task #333 above. The plan `sources` validation, overlap notices and the dashboard list (AC4 and the dashboard half of AC5) remain separate tasks; this one is the CLI half of AC1, AC2 and AC5.
|
|
5125
|
+
|
|
4350
5126
|
### Intake drafts (#511)
|
|
4351
5127
|
|
|
4352
5128
|
- A draft is a backlog card carrying `draft: true`. Backlog work is never offered, `hub_backlog_pull` refuses a draft, and the draft's children inherit the mark, so AC3 holds by construction.
|
|
@@ -4354,7 +5130,19 @@ The owner gave the #516 track its go-ahead on 2026-10-22. This section closes th
|
|
|
4354
5130
|
- With a planner, the hub sends it a `task` envelope of class `plan` that names the draft id. The request text goes through `publicView`. The planner answers with `hub_backlog_split` on the draft, giving each child a profile class, its sources, dependencies (`after`/`afterParts`) and acceptance criteria in the #507 criteria-id format.
|
|
4355
5131
|
- Without a planner, the hub model (`inference.ts`) proposes the same split as JSON. The hub validates it against the `hub_backlog_split` schema and writes it; invalid output leaves the parent draft unsplit, with the reason.
|
|
4356
5132
|
- PII in the request never goes to a planner peer, because PII turns refuse `hub_backlog_split` (`taskOpBody`). The hub's on-prem model proposes the split instead, under the existing triage screen, and the hub writes it itself through `Tasks` as private (PII) drafts, which show as stubs everywhere. Without an on-prem model, the draft stays unsplit for the person.
|
|
4357
|
-
- `ahub intake approve <id>` (person
|
|
5133
|
+
- `ahub intake approve <id>` (the person's, and since #933 the configured conductor's) clears `draft` on the parent and its children, which then follow the normal backlog pull. `ahub intake discard <id>` cancels them through `Tasks.cancel`.
|
|
5134
|
+
|
|
5135
|
+
#### Intake drafts implementation notes (hub task #334: `ahub intake`, `src/hub/intake.ts`)
|
|
5136
|
+
|
|
5137
|
+
- **Where the mark lives.** `draft` is a column on `tasks` (added by `ALTER` on an older database, default 0) and `Task.draft?: true`. Only `Tasks.propose` writes it, and only for the person's own `backlog: true` proposal (`by === USER`); a peer's `hub_task_propose` has no such field. `Board.splitBacklog` gives each child its parent's mark.
|
|
5138
|
+
- **Enforcement is at the board, not only at the callers.** `Board.pullPlanned` refuses a draft, and `Board.update` refuses to move a draft to any state but `backlog` or `cancelled`, so a path that forgot the check still cannot offer one. `Tasks` adds the readable refusals in `pullPlanned` and `moveBacklog`, skips drafts in the reservation auto pull (`topReservedReady`) and keeps them out of the planner's "Backlog items" notice.
|
|
5139
|
+
- **The request.** One parent draft: class `plan`, title `Intake: <first line>`, detail the text (1 to 7000 characters) and, on a last line, `Sources: <ids>`. Each `--source` must be a `src:<16 hex>` id the registry holds, at most 16 and distinct; all are checked before any card is made.
|
|
5140
|
+
- **With a planner**, the hub sends the first attached, rostered planner a `task` envelope (`taskRecipient: planner`) naming `hub_backlog_split` on the draft id at its planning revision, with the text from `publicView`, the sources and the criteria format. If no planner is reachable, or the envelope is not queued, the hub model takes it.
|
|
5141
|
+
- **Without one**, `Inference.split` (interactive clock, 60 s, 3000 tokens) answers JSON. `parseIntakeSplit` takes the bare object, a fenced block or prose around it, checks it against the `parts` property of the running profile's `hub_backlog_split` schema (a small validator over the keywords those schemas use: type, required, additionalProperties, min/max items, lengths, minimum, uniqueItems, enum, pattern) and then the rules `Tasks.splitBacklog` enforces anyway. It also requires every part's detail to carry acceptance criteria in the #507 `<id>: <text>` format and every `plan.sources` id to be one the request named. The hub writes the result through `Tasks.splitBacklog(HUB, ..., { intake: true })`, the only call where `by === HUB` passes the planner check. The hub's exception requires the parent to still be a draft at every validation through the final write: `Tasks.splitBacklog` checks it before and after the awaited screen, and sets `Board.requireDraftForNextSplit` for exactly that write so the board checks it again inside its transaction (an approval clears the mark without moving the planning revision, so the revision alone cannot show it). A person who approves the card while the model answers therefore wins: no child is created, and none is left undrafted. Any failure (no model, no answer, not JSON, schema, criteria, a refusal by `splitBacklog`, a draft that changed meanwhile) leaves the draft whole with a `hub` history entry `intake unsplit` naming the reason; the reason never contains anything the model wrote: it is built only from fixed words, array indexes and the schema's own property names. A property the schema does not list is reported as `parts[i]: an unexpected property`, without its name, so a hostile or private property name cannot reach the draft's history, which is not given the semantic screen the split parts get. A refusal from `splitBacklog` keeps the hub's own fixed refusal text, and any other error becomes `the board refused it`.
|
|
5142
|
+
- **PII.** A request whose draft is private is never sent to a planner. The model call needs `triage.onCampus()`; off campus the draft stays whole with that reason. On campus the hub's split goes through the unchanged `splitBacklog` screening, so the children are private drafts that show as stubs.
|
|
5143
|
+
- **Approve and discard.** `Tasks.approveIntake` accepts `USER` or the admitted conductor (#933 amendment), and `discardIntake` accepts only `USER`. The family is the draft and the still-open draft cards in its `deps`. Approval is one `Board.approveDrafts` transaction (every card must still be an unstarted, ownerless backlog draft, or none changes) that clears the mark and records `draft approved`; the cards then follow the normal pull. Discard calls `Tasks.cancel` on each card.
|
|
5144
|
+
- **The command.** `ahub intake "<text>" [--source <id>...]`, `approve <id>`, `discard <id>`, with the usual `--json` and `--color=`. It is not in `classifyPeerCommand`'s allowed list, so an agent shell is refused (except `approve` for the configured conductor (#933: see the amendment "the console class keeps trust, money, attribution and terminals")), and `liveTestRefusal` covers it. The audit label is `intake`, `intake approve` or `intake discard`, never the text. The request is 90 s at most on the control socket.
|
|
5145
|
+
- **Not in this slice.** The dashboard text box (AC1's second half) and a dashboard drafts view are #511b. Drafts show in `ahub backlog list` with `draft: true`.
|
|
4358
5146
|
|
|
4359
5147
|
### Schedules and folder triggers (#512)
|
|
4360
5148
|
|
|
@@ -4362,7 +5150,45 @@ The owner gave the #516 track its go-ahead on 2026-10-22. This section closes th
|
|
|
4362
5150
|
- Entry: `{id, every, class, title, detail?, sources?, owner?, queue?: "backlog" | "ready"}`. `every` is `<n>m|h|d` with a 15-minute minimum. `cron` is accepted by the schema but refused with "cron not supported yet; use every", because a cron parser is a new runtime dependency or new code; it is a later slice.
|
|
4363
5151
|
- Due checks run on their own interval timer, started whenever `schedules` or folder triggers are configured, independent of `task_sweep.enabled` (the task sweep's timer exists only when that option is on, and it defaults to off). The timer runs no task sweep, and it is re-armed on config reload. `queue` defaults to `backlog`. Missed runs coalesce: a schedule proposes at most one task per tick, and none while its previous task is still open. A global pause or hold skips the tick and coalesces it. Last and next run times live in `hub.db`, and status and the dashboard show the next run.
|
|
4364
5152
|
- Folder trigger: `{id, folder, glob?, class, title, ...}`. `folder` is resolved with `realPath`, must lie inside the project or a declared data path, and is polled on the schedules timer, not watched. Symlinks that leave the folder are skipped. A new file (by `realPath`, size, mtime and SHA-256 recorded in `hub.db`) is registered as a source (#508) and proposes one task that names it. The trigger therefore lands after the source registry.
|
|
4365
|
-
- `ahub schedule list|run <id>|pause <id>|resume <id>` is person-only.
|
|
5153
|
+
- `ahub schedule list|run <id>|pause <id>|resume <id>` is person-only for `list`; `run`, `pause` and `resume` are also the configured conductor's since #933 (implementation notes in #512b below).
|
|
5154
|
+
- Folder triggers are their own machine-local key, `folder_triggers` (implementation notes in #512c below).
|
|
5155
|
+
|
|
5156
|
+
#### Schedules (#512a) implementation notes (hub task #330: `schedules`, `src/hub/schedules.ts`)
|
|
5157
|
+
|
|
5158
|
+
Where the code states or fixes a detail the section above leaves open:
|
|
5159
|
+
|
|
5160
|
+
- **Where the timer runs.** `schedulesTimer` in `startDaemon` is its own interval (default one minute, `schedulesEveryMs` in tests), armed at startup and re-armed by `reloadConfig` when a `schedules` leaf changes. It exists only while `schedules` is non-empty, and it never reads `task_sweep.enabled`. The pass is `Tasks.proposeFromSchedules`; the daemon only calls it and writes `status.json` afterwards.
|
|
5161
|
+
- **Period.** A schedule's period starts at its anchor in `schedule_runs` (`hub.db`): the first time the hub saw it, then each run. A schedule never runs at first sight, and a restart or a hold never replays what it missed: a run restarts the period at `now`. Changing `every` takes effect from the same anchor. A schedule removed from the config loses its row.
|
|
5162
|
+
- **Coalescing.** A due schedule proposes at most one task per pass. While the task its previous run made is open, or while the hub is held (stopping, recovering, a storage error), a due pass proposes nothing and counts a skip (`coalesced`); the next run after the cause ends is the one task they fold into.
|
|
5163
|
+
- **Which task is "its previous task".** The open card whose first history entry is by the hub and whose detail starts with `Schedule key: <id>`, read from the board, never from `hub.db`. So a task closed by anyone, or a `hub.db` row that was lost, cannot make the answer wrong, and a task a person or peer wrote to quote the marker does not count.
|
|
5164
|
+
- **Holds are fenced at the write, and stop drains the pass.** `propose` awaits the PII screen and triage, and a hold (stopping, recovery, a storage error, the test seam) can begin during them, so `proposeFromSchedules` hands `propose` a fence that `requestCheck` asks again right before the board write. A run overtaken that way writes nothing and counts as a skip, never as the schedule's error. Normal shutdown clears `schedulesTimer`, waits for a pass in flight (bounded at five seconds, after which the pass is still refused by the fence), and closes `schedulesStore` before the board; a pass that ends during shutdown does not republish `status.json`. A recovery commit waits for a pass in flight (`Tasks.schedulesPending`, in `recoveryReady()` and the blockers as "a scheduled task proposal in flight"), like it waits for completion checks and automatic pulls, so no scheduled run writes after the commit's digest. An ordinary `propose` takes no fence.
|
|
5165
|
+
- **How the task is made.** Through `Tasks.propose` as the hub (backlog for `queue: "backlog"`, a routed proposal for `"ready"`), so the PII screen, triage, class validation, routing and notices all apply. A proposal `Tasks` refuses is recorded as the schedule's `last_error`, shown in status, and retried at the next tick (the period is not restarted by a failure).
|
|
5166
|
+
- **Status.** `schedules` in the daemon status holds, per schedule: id, every, class, queue, next run, last run, the task, a state (`scheduled`, `due`, `waiting`, `held`), skipped runs and the last error. It never holds a title or detail, because those are task text. `ahub status` prints one `schedule <id>` line each.
|
|
5167
|
+
- **Config.** `schedules` is in `MACHINE_LOCAL` and `NEVER_EDITABLE` (a settings write cannot create work), is validated by `schedulesConfig` in the loader and in `config-apply`, and applies live. At most 64 entries, ids of lower-case letters, digits, `-` and `_`, details of at most 7000 characters (so the marker line is never cut off), at most 16 `sources` ids of the `src:<16 hex>` form.
|
|
5168
|
+
- **Not in this slice.** `sources` is carried into the task's detail as `Source: <id>` lines; it is not yet validated against the registry or attached as `plan.sources` (that is the plan-validation slice of #508). Folder triggers and `ahub schedule list|run|pause|resume` stay with their own tasks.
|
|
5169
|
+
|
|
5170
|
+
#### Schedule commands and the dashboard (#512b) implementation notes (hub task #335: `ahub schedule`, `src/cli/schedule-output.ts`)
|
|
5171
|
+
|
|
5172
|
+
- **Person or conductor, three layers** (`run`, `pause` and `resume` also admit the configured conductor, #933: see the amendment "the console class keeps trust, money, attribution and terminals")**.** `schedule` is not in `classifyPeerCommand`'s allowed list, so an agent shell is refused before the command runs, `liveTestRefusal` covers it, and the daemon's console-op gate and `Tasks.runScheduleNow` and `setSchedulePaused` each refuse any actor but the person. The audit label is `schedule <list|run|pause|resume>`, never an id (an id is config text).
|
|
5173
|
+
- **`list`** prints `scheduleViews` as a table: id, every, class, queue, state, next run, last run, task, then the skipped-tick count and the last error as notes. No title or detail appears anywhere: both are task text.
|
|
5174
|
+
- **`run <id>`** proposes the schedule now, whatever its period says, through `runSchedule`, the same code the timer's pass calls, so a manual run can never do what a due one may not: it is refused as `held` while the hub is stopping, recovering or on a storage error, refused as `waiting` while the task its previous run made is open, and otherwise goes through `Tasks.propose` as the hub (the PII screen, triage, routing and notices all apply). It takes the same single pass guard as the timer (`schedulesRunning`), so it never overlaps a pass and recovery's `schedulesPending` wait still covers it; while a pass runs it is refused with "a schedules pass is running". A successful run restarts that schedule's period at now, like a due run. A refused manual run is not a missed period, so it does not feed the coalescing count.
|
|
5175
|
+
- **Pause and resume are ordered against a run in flight.** `ScheduleStore` keeps a per-schedule pause generation in memory (`epoch`, bumped by `setPaused`). A timer run captures it when it is admitted, and the write fence `propose` asks again right before the board write also compares it, so a pause or resume that lands while the run awaits its PII screen cancels that run (outcome `changed`, nothing written, no skipped tick): the acknowledged command is what takes effect. `ScheduleStore.ran` also moves the anchor with `MAX(anchor, now)`, so a pass that finishes after a later resume can never shorten the fresh period. A person's own `run` is outside the epoch: pausing during it does not cancel it. A restart ends every run in flight, so the generation needs no storage.
|
|
5176
|
+
- **A manual run is drained at shutdown.** `schedule_run` is registered in `manualSchedulePasses`, which `stopOnce` waits for (bounded at 5 s, like the timer's `schedulesPass`) before the peers stop and the board and the schedule store close, so a run that created its task and is still awaiting assignment delivery finds its bookkeeping (`ran`, `failed`) written to an open store. A run that arrives once stop has begun is refused by the control layer ("hub is stopping").
|
|
5177
|
+
- **`pause <id>` and `resume <id>`.** A `paused` column on `schedule_runs` (added by `ALTER` on an older database, older rows read as not paused). The timer skips a paused schedule without a run and without counting a skipped tick. `resume` clears the flag and starts a fresh period at now, so a long pause is never replayed as a run the moment it ends, as a restart never is. A paused schedule still runs on `run <id>`: a pause stops the timer, not the person. `pause` of a schedule not yet seen records it, paused.
|
|
5178
|
+
- **View.** `ScheduleState` gains `paused`, which wins over every other state. `ahub status` reads `paused (ahub schedule resume <id>); would be next <time>`.
|
|
5179
|
+
- **Dashboard.** The UI snapshot carries `schedules` (the same views, ids, enums and times only; absent when none is configured, so an older page and an unconfigured project see no change). The Agents view has a read-only Schedules table, built from the existing `usage-table` classes so no style changes, with a next-run column worded by `scheduleNextLabel`: the time for `scheduled`, `Paused (would be <time>)`, `Due now`, `Due, waiting for task #N to close` or `Due, held`, and `unknown` for anything else, never an invented time. The dashboard has no run, pause or resume control: those stay the person's CLI commands.
|
|
5180
|
+
- **Not in this slice.** Folder triggers (#512c) and cron remain refused or absent.
|
|
5181
|
+
|
|
5182
|
+
#### Folder triggers (#512c) implementation notes (hub task #336: `folder_triggers`, `src/hub/folder-trigger.ts`)
|
|
5183
|
+
|
|
5184
|
+
- **Config.** `folder_triggers` is a separate machine-local key (not inside `schedules`, whose entries are time-based), in `MACHINE_LOCAL` and `NEVER_EDITABLE`, validated by the pure `foldersConfig` in the loader and in `config-apply.ts`. Entry: `{id, folder, glob?, class, title, detail?, owner?, queue?}`, at most 16. `glob` is a file-name pattern (letters, digits, `. _ -`, space, `*`, `?`; never a path) that matches whole names; a dot file never matches.
|
|
5185
|
+
- **Where the folder may lie.** `checkTriggerFolder` runs the corpus root check (`checkCorpusRoot`: `realPath`, vault refusal, `local.deny`, `pii.paths`) and then requires the real folder to lie inside the project or a declared `data.readonly` folder, both as real paths, so a link out of either is judged by where it lands. The answer is a reason string, never a throw; a refused folder records `last_error` and proposes nothing.
|
|
5186
|
+
- **Polled, never watched.** The pass runs on the schedules timer (`runSchedulesOnce`), under the same `schedulesRunning` guard as a schedules pass and `ahub schedule run`, so it never overlaps one and a recovery commit waits for it. While the hub is held it reads and records nothing, so the files wait for the next poll.
|
|
5187
|
+
- **Baseline.** A trigger's baseline is the files already in the folder when it starts. They are recorded and propose nothing, so adding a trigger to a full inbox does not flood the board. A folder too big to hash in one poll (below) is baselined over several polls: each records what it read, and the poll that reaches the end of the folder completes the baseline (`folder_poll.baselined`); until then nothing proposes, and a file that arrives meanwhile is part of the baseline. A refused folder never begins the trigger: when it becomes readable, what is in it then is the baseline.
|
|
5188
|
+
- **What is read, and the memory bound.** Regular files directly inside the folder (no recursion), in name order. The listing is never cut (a folder of more than 100000 entries is refused as not an inbox), so a file late in name order is reached once the earlier ones are recorded, however many hidden or unmatched names sort before it. A symlink is never read: one that leaves the folder is skipped as `symlink leaving the folder`, one that stays as `symlink`. A file on the denylist is skipped. The scan keeps no file's bytes: a file already recorded at its real path, size and mtime returns its recorded hash after an `lstat` and is not opened; any other is opened with `O_NOFOLLOW`, checked against its `lstat` (size and inode) and hashed in 64 KiB chunks from that descriptor. At most 200 files and 256 MiB are hashed per scan (the first file always is, so one file near the source cap still goes); the rest wait for the next poll. Registering a file reads that one file again (`readScanned`) and refuses it unless it still hashes to what the scan saw, so at most one file's bytes are in memory and a file that changed in between is tried again as what it became. A file whose bytes change without its size or mtime changing is not noticed (a `ponytail:` note names the upgrade: hash every poll under the same budget).
|
|
5189
|
+
- **What is new.** `folder_seen` in `hub.db` records, per trigger, the real path, size, mtime and SHA-256. A file is new when its hash is not recorded for that trigger, asked again for each file as the pass goes, so two files with the same new bytes that arrive together propose ONE task. A rename and a touch keep the hash, so they propose nothing; the same bytes under another name are the same file (the source registry keeps one record per hash too). Such a file is still recorded where it now is (`FolderStore.observe`: its new path, size and mtime, keeping the task its content already has), so the next scan finds it with `known` and does not hash it again; otherwise a pile of renamed, touched or duplicate files would spend the hash budget on every poll and a genuinely new file behind them would never be reached; a path whose bytes changed has a new hash and is new. A deleted file never proposes.
|
|
5190
|
+
- **What a new file does.** At most 20 per trigger per pass, in name order. For each, the hub screens it exactly as `ahub source add` does and registers it, in `registerScanned` (`screenRegistration`: `pii.paths` and `signals.pii_patterns` over the projected text, then `SourceRegistry.add`, private when flagged), the one function the daemon and the tests share, then proposes ONE task through `Tasks.propose` as the hub, behind the same hold fence as a schedule. The task's detail is `Folder trigger: <id>`, a fixed line, the person's `detail`, and `Source: <src id>`; it never carries the file's name or path (both can be private), so a PII file's task is as public as its id. A file is recorded only after its task exists: a failure or a hold between the two leaves it to be tried again, and later files wait behind it so order is kept. A crash in that window can repeat one task; it never loses a file.
|
|
5191
|
+
- **Not in this slice.** `plan.sources` validation of the task against the registry, a status or dashboard view of triggers, and recursive folders.
|
|
4366
5192
|
|
|
4367
5193
|
### Deliverables (#509)
|
|
4368
5194
|
|
|
@@ -4374,12 +5200,57 @@ The owner gave the #516 track its go-ahead on 2026-10-22. This section closes th
|
|
|
4374
5200
|
- A corpus path that lies in, is, or contains an Obsidian vault is refused. The check runs on the `realPath` of the configured root, so a symlink into a vault is caught. It looks for a `.obsidian/` directory in every ancestor of that real path (a root inside a vault, such as `/vault/notes` when `/vault/.obsidian` exists), at the root itself, and below it. The index walk also skips no `.obsidian/` it meets; it refuses the root instead. Evidence it cannot read (an unreadable ancestor or subdirectory, or a realPath failure) refuses the path; it never certifies a plain folder. The reason names MCP: a vault is reached only through the Obsidian MCP tools, never read by the hub from disk.
|
|
4375
5201
|
- AC3 (vault candidates) stays with agent-mem (tasks 112 and 122); the hub adds no vault reader.
|
|
4376
5202
|
|
|
5203
|
+
#### Knowledge corpus (#510a) implementation notes (hub task #331: `corpus.paths`, `src/hub/corpus.ts`)
|
|
5204
|
+
|
|
5205
|
+
Where the code states or fixes a detail the section above leaves open:
|
|
5206
|
+
|
|
5207
|
+
- **Config.** `corpus: { paths: string[] }` is validated by `corpusConfig` (`src/hub/corpus-config.ts`, pure, so the channel bundle that reaches the config schema never pulls in file-system code). `corpus.paths` is in `MACHINE_LOCAL` and `corpus` is in `NEVER_EDITABLE`, so only an untracked `config.local.json` sets it and a settings write cannot. At most 16 folders; each is an absolute or project-relative path, and `~` is refused rather than expanded. It applies at startup (the index is bound to its roots then).
|
|
5208
|
+
- **Root check (`checkCorpusRoot`).** Order: resolve the path with `policyRealPath` then `realPath` (a symlink into a vault resolves to the vault), then look for any entry named `.obsidian` at the real root and in every ancestor of it, then the deny rules (`isDenied` with `local.deny`), then `pii.paths` (`matchesPolicy`). Anything that cannot be read, resolved or listed refuses the root with its cause; only a clean read certifies it.
|
|
5209
|
+
- **Walk (`CorpusIndex.refresh`).** Bounded at 4096 folders, 100000 entries, depth 24 and 5000 notes of at most 256 KiB. Any entry named `.obsidian` (a folder or a file, folded spelling too) below the root refuses the whole root, and a tree too deep or too big to be shown free of a vault is refused, never cut short. A folder that `pii.paths`, the deny rules or `local.deny` protect is never listed or read, so a vault anywhere below it cannot be ruled out: **a root that holds a protected folder is refused**, naming the folder, the same fail-closed rule as for unreadable evidence (the owner decision). The person points `corpus.paths` at a folder with no protected folder in it. A protected note (a file) is only skipped and counted as `protected`, since a file cannot hold a vault. Symbolic links and multiply-linked files are skipped and counted, and notes are opened with `O_NOFOLLOW` and checked against the walked identity. A note matching `signals.pii_patterns` (or whose pattern cannot be evaluated) is not indexed.
|
|
5210
|
+
- **Index.** In memory, rebuilt at startup, incremental by device, inode, size and times: an unchanged note is not read again. It stores the id `note:<root label>/<path>`, title, frontmatter, tags, aliases, headings, wikilinks and a SHA-256, never the note's text, and it never writes into the folder. A refusal after a note was indexed drops that root's notes on the next refresh.
|
|
5211
|
+
- **Parser.** `parseNote` reads flat frontmatter (`key: value`, `key: [a, b]`, block lists), ATX headings and `[[target#heading|alias]]` / `![[embed]]` links outside code, with caps on headings, links, tags and frontmatter size, and it reports `truncated`. It is not a YAML parser (zero runtime dependencies); nested maps are ignored.
|
|
5212
|
+
- **Departures from the accepted plan.** `CorpusIndex.resolveLink` returns the matching `NoteRecord[]` (not ids), so a caller gets the title and path without a second lookup. `CorpusContext` has no `io` seam: the tests use real temporary folders, so the walk, the identity check and the vault refusal are exercised for real, and `piiPaths` and `deny` are optional (absent means none). Neither changes what the index accepts or refuses.
|
|
5213
|
+
- **Not in this slice.** No daemon wiring or status line yet, no `ahub ask` or brief citations (AC2), no vault candidates (AC3, agent-mem) and no PII task handling downstream (AC4). The index is a library the next slice calls.
|
|
5214
|
+
|
|
5215
|
+
#### Corpus evidence in ask and task briefs (#510b)
|
|
5216
|
+
|
|
5217
|
+
- The corpus index retains structure, not bodies. Evidence reads reopen selected notes through current vault, deny and `pii.paths` checks, regular-file/no-follow identity and hash checks, and a full-text privacy screen before selecting any excerpt. A policy revision creates a fresh index. A PII question or private task context never queries corpus notes or shared observers.
|
|
5218
|
+
- Retrieval is seeded from screened title, path, tags, aliases and headings. At most 32 candidate bodies are read, six evidence items are returned, each text is at most 768 UTF-8 bytes, and complete corpus evidence including ids fits 4096 bytes. A cut excerpt is marked. This bounded metadata-first slice does not claim a full-text search index.
|
|
5219
|
+
- Evidence ids use `note:<root-label>/<path>#<heading-slug>`. Slugs preserve Unicode letters/numbers and deterministically disambiguate duplicates, including the headingless `top` section. Backslashes, commas, brackets and fragment delimiters inside id parts are escaped with a backslash; Unicode remains literal. Control-bearing or over-1024-byte ids are not published. Models must copy the complete escaped id exactly; invented note ids cause the answer to be discarded.
|
|
5220
|
+
- Corpus rows supplement amem brief rows within the existing item cap. Plan fails retain priority and library ordering remains authoritative. Delivery dedupe, policy/task freshness and private-turn guards apply before retrieval and again before observation or delivery. Corpus rows are not counted as native memory rows.
|
|
5221
|
+
- A PII ask remains evidence-only when campus status is false or unknown. On a positively confirmed campus gateway it uses `onCampusOnly: true` and pins the host's `routing.local.fixed_model` before any gateway await, using the existing repository on-prem rule. A changed socket, caller-private context or policy invalidates the result, as does a change to the PII or note-PII verdict of any gathered task row; unrelated board writes and public history/status changes do not invalidate snapshot evidence; a failed campus-only transport never falls back to a cloud call. No stronger provider trust tier is introduced here.
|
|
5222
|
+
|
|
4377
5223
|
### On-prem web reading (#515), owner decision 2026-10-22
|
|
4378
5224
|
|
|
4379
5225
|
- `web_fetch` is refused for the whole turn of a PII task, whatever the host. The outbound URL or query could carry PII to the web, and `pii.paths` and `sanitize()` do not cover outbound requests.
|
|
4380
5226
|
- Outside PII turns it runs only through the egress proxy, to hosts in a machine-local per-project allowlist, and returns bounded text only.
|
|
4381
5227
|
- `doc_read` is unaffected by this rule; it keeps its own `pii.paths` and `data.readonly` checks.
|
|
4382
5228
|
|
|
5229
|
+
#### On-prem document reading (#515a) implementation notes (hub task #320: `doc_read`, `src/local/doc-read.ts`)
|
|
5230
|
+
|
|
5231
|
+
Where the code states or fixes a detail the section above leaves open. `web_fetch` is a separate task and waits for its owner decision.
|
|
5232
|
+
|
|
5233
|
+
- **Opt-in.** `local.doc_read: true` (machine-local: in `MACHINE_LOCAL`, and never editable from settings) gives the local worker a `doc_read` tool. Off by default: `DOC_READ_SCHEMA` is not in `TOOL_SCHEMAS`, the worker offers it only when the daemon passes a `docRead` port, and a model that names it anyway gets "doc_read is not enabled". Pi shares `TOOL_SCHEMAS` and does not get it.
|
|
5234
|
+
- **What it reads.** The model names `path` (and `offset`, `limit` as for `read`). The path goes through `guardPath` in read mode, the same guard as `read` and the one #418 shares (inside the project after `realPath`, no link out, no secrets denylist, `local.deny`). Then the file must be a regular file with one name and at most 16 MiB. The converter is the person's `text_projection` entry for the extension (PDF, DOCX, HWPX are whatever the person configured), run by `TextProjections` in its seatbelt profile on a private copy of the bytes, bounded by its input, time and output caps and cached by content in the hub state directory. An extension with no entry is refused with the way to configure one. The hub never picks a converter.
|
|
5235
|
+
- **Screens, in order.** `pii.paths` by the document's path (`matchesPolicy`, so globs and folded spellings count): an ordinary turn is refused before anything is converted. After conversion, a text matching `signals.pii_patterns` (or a pattern that cannot be evaluated) is refused the same way. A PII task turn is the case these refusals point to, so it reads both: that is what the on-prem worker is for. Then `sanitize()` quotes any line that forges a hub header, and the output is cut at the shared output cap with how to continue (`offset`).
|
|
5236
|
+
- **`data.readonly`.** A read is not a write, so the policy is not a reason to refuse. What it guards holds: the converter runs read-only in its sandbox, its cache is in the hub state directory, and `doc_read` writes nothing beside the document.
|
|
5237
|
+
- **Never memory evidence.** Converted text is document content (a converter can surface hidden text), so the result is excluded from claude-mem capture and from the research observation on every turn, not only a PII turn.
|
|
5238
|
+
- **Tests.** Fakes stand in for the projections (the converter itself is `text-projection.test.ts`'s subject); one macOS leg runs a real shell converter through `TextProjections`. The live leg on the on-prem worker with a real PDF, DOCX and HWPX converter is a separate person leg.
|
|
5239
|
+
- **Not in this slice.** `web_fetch`, which waits for an owner decision, and an async conversion: it is synchronous in the daemon process, bounded by the converter's 8 s timeout (ponytail comment in `doc-read.ts`).
|
|
5240
|
+
|
|
5241
|
+
#### On-prem web reading (#515b) implementation notes (hub task #332: `web_fetch`, `src/local/web-fetch.ts`)
|
|
5242
|
+
|
|
5243
|
+
Where the code states or fixes a detail the section above leaves open. `doc_read` is unaffected.
|
|
5244
|
+
|
|
5245
|
+
- **Opt-in, per project.** `local.web_fetch: { allow: [hosts] }` in `.agenthub/config.local.json`. The tool exists for the local worker only when `allow` names at least one host: with the default (an empty list) `WEB_FETCH_SCHEMA` is not in the worker's tool list, no proxy is started, and a model that names the tool anyway gets "web_fetch is not enabled". Machine-local (`MACHINE_LOCAL`, so a committed config is ignored), never editable from settings (`NEVER_EDITABLE`), bound when the hub starts (`LOCAL_RESTART_REASONS`). Pi shares `TOOL_SCHEMAS` and does not get it.
|
|
5246
|
+
- **The allowlist.** Each entry is a host name, optionally `:port` (443 unless named): letters, digits, `-` and `.`, at least one dot, never a scheme, path, wildcard, address or bare top-level name. At most 64, lower-cased and de-duplicated. It is checked by the pure `webFetchAllow` (`src/local/web-fetch-config.ts`, imported by the config schema, so it stays free of the disk and process modules the Claude channel bundle must not reach) when the config loads. A name also allows its subdomains, by the same `allowed()` rule the proxy uses.
|
|
5247
|
+
- **PII, first.** `webFetch` returns the same fixed refusal for the whole turn of a PII task before it reads the URL, the allowlist or the proxy, whatever the host: nothing about the request changes the answer or the timing, and no request is made. The worker passes the turn's PII flag into the tool context (`toolContext`), as it does for `doc_read`.
|
|
5248
|
+
- **Only through its own egress proxy.** The daemon starts a second `startEgressProxy` for `web_fetch` whose list is `local.web_fetch.allow` and nothing else (it follows a reload through `allowFn`). It is never the bash proxy: that one carries `network_allow` (package registries and code hosts by default), so listing a host for a model to read opens nothing to a shell command and the reverse. The proxy keeps its own rules: CONNECT only (TLS stays end to end), and a listed name that resolves to a loopback, private or link-local address is refused. With no proxy running the tool makes no request.
|
|
5249
|
+
- **What the model controls.** Only `url`, `offset` and `limit`. The request is a bare GET with a fixed `Accept` and `User-Agent`: no method, header, body or proxy from the model. The URL must be https, at most 2000 characters, with no space or control character, no user name or password, no address literal, and a host (and port) on the list; a fragment is dropped. Redirects are not followed by the client (`redirect: "manual"`): each `Location` is resolved and checked against the same rules before it is requested, at most three.
|
|
5250
|
+
- **What comes back.** Only `text/*`, JSON and XML types are read; anything else is refused without reading the body. The body is read at most 512 KiB deep (the stream is cancelled past it, never drained) within 15 seconds. HTML is reduced to text (scripts, styles, comments, `svg`, `head` and tags go, block tags break lines, a few entities are decoded), then `sanitize()` quotes a line that forges a hub header and the output is cut at the shared output cap with how to continue (`offset`). A failure is a fixed sentence plus at most an error code, never the error text.
|
|
5251
|
+
- **Never memory evidence.** The result and the URL are excluded from claude-mem capture and from the research observation on every turn, like `doc_read`: a page is a third party's text and the URL is the model's own request.
|
|
5252
|
+
- **Not in this slice.** There is no `ahub` command for it: the list is edited in `config.local.json`. A request is not asked of the person (the allowlist is the person's consent, given in advance). `robots.txt`, caching, cookies, authenticated pages and non-text types are not handled.
|
|
5253
|
+
|
|
4383
5254
|
## Amendment: roles per SDLC stage (#525)
|
|
4384
5255
|
|
|
4385
5256
|
Written against main `8efdba48` (v0.12.45 + #671). Claims about current code carry `file:line` at that commit. "Probe" means a read-only `bun` run of `validateInstances` / `resolveSettledRoles` against that commit on 2026-10-22. Anything marked unverified was not run or read.
|
|
@@ -4524,9 +5395,9 @@ Headless and terminal forms of one kind coexist as separate ids. Neither reviews
|
|
|
4524
5395
|
- Reviewer: done (#535, `review_mode: "fresh"`); native continuing `["reviewer"]` instances keep #519's rule. No change.
|
|
4525
5396
|
- Planner: a Codex instance whose roles hold `planner` and neither `implementer` nor `verifier` is a planner instance. It is accepted today (probe); this amendment adds:
|
|
4526
5397
|
- no worktree (refused, it plans on the project checkout);
|
|
4527
|
-
- its app-server threads start with the read-only sandbox
|
|
5398
|
+
- its app-server threads start with the read-only sandbox: the host supplies a `CodexOptions.threadSandbox` value or host-only live getter (`"read-only" | "unavailable" | undefined`), and both `thread/start` and `thread/resume` send `sandbox: "read-only"`. The matching native reply must carry the known top-level `sandbox: { type: "readOnly", networkAccess?: boolean }` policy; missing, malformed or writable policy refuses that thread RPC and closes the unconfirmed link before adoption or hub delivery. The live getter is rechecked at thread admission, reply and hub injection: a live role transition cannot reuse a writable or unconfirmed thread as a planner, and a planner with a declared worktree is unavailable. Getter failure is unavailable. The hub start RPC only opens the proxy, never creates a host thread or waits for a TUI-created one. Request/response shapes are model-free schema evidence from Codex CLI 0.164.0, not a native/account execution receipt. Unknown is never read-only. The attached TUI is the person's and may change later native turn policy; this remains a ceiling, not a locked sandbox claim;
|
|
4528
5399
|
- owner admission already refuses it for code classes (no implementer role, `routing.ts:401`); it may own `plan`-class tasks.
|
|
4529
|
-
- Claude planner instances stay channel instances (I3). For a declared planner-only Claude instance the facts hook grants nothing for mutating file tools or
|
|
5400
|
+
- Claude planner instances stay channel instances (I3). For a declared planner-only Claude instance the authenticated facts pre-reply carries `mutationGrants: false` and lowers the existing `permission` field to `ask` for every non-read call, so older hooks also receive no automatic mutation grant without changing the person's runtime mode; the hook grants nothing for mutating file tools, shell or unknown tool kinds, whatever its permission mode; Claude's own prompt goes to the person in that terminal. The builtin `claude` is unchanged.
|
|
4530
5401
|
- Planning writes go through `Tasks` as today.
|
|
4531
5402
|
|
|
4532
5403
|
### R7. Visibility
|
|
@@ -4639,7 +5510,7 @@ resumeFromRoster?(): Promise<void>;
|
|
|
4639
5510
|
/** Fixed builtins only: a person's frontend attaches to the native session through this hub proxy. */
|
|
4640
5511
|
readonly frontendProxy?: FrontendProxyPort;
|
|
4641
5512
|
/** Fixed builtins only: a fixed operator notice; never native ids or provider text. */
|
|
4642
|
-
readonly statusNotice?: { code: "unidentified-cancellation"; message: string };
|
|
5513
|
+
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
|
|
4643
5514
|
/** Fixed builtins only, and only with session.nativeRevert: roll the native conversation back to before this native turn. */
|
|
4644
5515
|
revertTurn?(nativeTurn: string): Promise<void>;
|
|
4645
5516
|
```
|
|
@@ -4671,7 +5542,7 @@ Who sets what (parity by construction; each value is today's member, renamed at
|
|
|
4671
5542
|
| `detachForRoster` | `WsPeer` (ignores the mode, `channel-peer.ts:91`), `CodexPeer` (`codex-appserver.ts:473`), `PiPeer` (`pi.ts:471`) | `daemon.ts:4758-4760` |
|
|
4672
5543
|
| `resumeFromRoster` | `PiPeer` (`pi.ts:496`) | `daemon.ts:4773` |
|
|
4673
5544
|
| `frontendProxy` | `CodexPeer`: `url` = `proxyUrl` (`:137`), `attached` = `proxyAttached` (`:488`), the two methods as at `:494`, `:500` | `daemon.ts:3701`, `:5198-5280` |
|
|
4674
|
-
| `statusNotice` | `CodexPeer` (`
|
|
5545
|
+
| `statusNotice` | `CodexPeer` (`unidentified-cancellation`) and the existing `QwenDualPeer` (`qwen-detached`, `qwen-stalled`), unchanged | fixed-builtin-gated status projection |
|
|
4675
5546
|
| `revertTurn` | `CodexPeer.revert` (`codex-appserver.ts:610`) | `daemon.ts:2446`, `:2459` |
|
|
4676
5547
|
|
|
4677
5548
|
Per-port absent and undefined semantics:
|
|
@@ -4924,7 +5795,7 @@ Common acceptance criteria:
|
|
|
4924
5795
|
- `frontendProxy` undefined refuses permission changes and `already`.
|
|
4925
5796
|
- The `unidentified-cancellation` notice shows only from a fixed builtin.
|
|
4926
5797
|
- The Codex sticky-override, restoring-turn, detached-clear and attached-refusal tests stay unchanged.
|
|
4927
|
-
- `isCodexPeer` rows leave the table.
|
|
5798
|
+
- The proxy/startup/revert `isCodexPeer` rows leave the table. The later `actionEnforcement` branch remains intentional: it reads Codex `actionGated` to choose the existing enforceability refusal. It is outside 2d and waits for a separately specified action-policy port; no `actionGated` member is added to `FrontendProxyPort`.
|
|
4928
5799
|
5. **2e: Claude launch proof extraction.** Area note: `daemon.md` (Claude launch and hook rules). Size M.
|
|
4929
5800
|
- Paths: new `src/hub/claude-launch-proof.ts`, `src/hub/daemon.ts` (hello, `permissionDisplay`, `changePermission`, facts), tests.
|
|
4930
5801
|
- No change to `src/cli/launch.ts`, `src/cli/facts-hook.ts`, `src/adapters/claude-channel.ts` or the plugin bundle.
|
|
@@ -5072,6 +5943,8 @@ Periodic roster and supervision reads use pending delivery metadata/rows rather
|
|
|
5072
5943
|
|
|
5073
5944
|
Slice 525-5 is implemented. The temporary #735 blanket refusal is replaced by the existing implementer-role and host owner-isolation gate for every write class. The bounded launch-proof reader binds the current daemon instance and launch id; owner proof additionally needs a private, single-link launch record, a matching recorded execution root, the MCP server's real cwd and the validated noncanonical branch worktree. The proof belongs to the exact attached claim generation and socket; a pending preface is not ownership, and detach, replacement or a changed launch record revokes it. Old/missing root evidence attaches without isolation. Facts grants for declared implementers and terminal-recovery publication require the current proof. Canonical planner/reviewer behavior and protocol 16 remain unchanged. The launcher preserves canonical AGENTHUB_PROJECT_DIR for hub identity while passing the execution root as native cwd. These are same-user process/record attestations, not a sandbox.
|
|
5074
5945
|
|
|
5946
|
+
Status isolation fields and their read-only stall projection use retained host bindings plus cheap current-owner/generation/socket checks. They perform no terminal-root Git validation and grant no authority. Filesystem/branch or launch-record drift may remain unseen in that display until the next routing admission, facts permission reply or terminal recovery owner proof; those paths still revalidate and revoke mismatches before effects. The status snapshot is passed explicitly through read-only stall evaluation only, never through an ambient mode or a request-controlled bypass. Unrelated external-instance evidence and ordinary checkout probes keep their existing contracts.
|
|
5947
|
+
|
|
5075
5948
|
## Amendment: planner walk and reviewer independence in `assign()` (#525-3)
|
|
5076
5949
|
|
|
5077
5950
|
Slice 525-3 is implemented, as the "Purity" bullet of the #525 amendment specifies; this records what exists and what does not yet.
|
|
@@ -5079,7 +5952,7 @@ Slice 525-3 is implemented, as the "Purity" bullet of the #525 amendment specifi
|
|
|
5079
5952
|
- **Planner walk.** `plannerWalk` (`src/hub/routing.ts`) is a pure function of the sorted settled holders, the task id, an optional named planner, an optional kept planner and a per-holder `blocked` reason. A PII task has no planner (`planner: none (pii: planning stays with the person)`). A named planner is exact: a name that is not a holder is refused in the trace and never replaced by rotation, and a named holder that is paused or offline is still the choice, with the state shown. Otherwise a recorded planner is kept while it is still a holder. Otherwise rotation starts at `task.id mod n` over the holders sorted by id and walks forward past unavailable holders; the denominator is every holder, so availability never renumbers it (the #519 rule); when all are unavailable the seed holder is taken, `rotation (none available)`.
|
|
5080
5953
|
- **Inputs and outputs.** `assign()` takes `planners`, `namedPlanner` and `keptPlanner` and returns `Assignment.planner` and `plannerReason` (`rotation`, `named`, `kept`, `none`). Without `planners` it walks no planner, so a caller that does not pass them is unchanged. A holder is unavailable when the adapter role ceiling excludes planning, it is off roster, not attached, offline, paused under `budget_paused = skip_peer`, or failing: the same state inputs the owner and reviewer walks read. The walk is independent of both: planner and owner, or planner and reviewer, may be the same peer.
|
|
5081
5954
|
- **Explain parity.** The walk's lines (` planner candidate X: ...`, then `planner: X (reason)`) are part of `assign()`'s trace, after the owner and before the reviewer. `Tasks.weights()` supplies `planners` (the rostered holders of the settled role map, sorted) to every `assign()` path, so `ahub route explain` prints exactly what assignment ran.
|
|
5082
|
-
- **I-2.** A peer with an `accepted` or `done` history entry on the task never reviews it (`implementers`), whoever owns it now, a named reviewer and the declared reviewer pool included: refusal code `policy`, reason `implemented this task`. It is the last check in `blocked()`, so it only ever removes a candidate that was otherwise eligible: a former implementer that is offline, not attached, paused, failing or off roster is refused for that real reason and leaves the outcome as it was before I-2 (a task with no other reviewer still approves directly). When I-2 removes the last eligible candidate, the reviewer is the person and `reviewBlocked` is set (`every eligible reviewer implemented this task: the person reviews`); there is no fallback to an implementer. The person's own entries are not an implementation. A review-class and a PII task are unchanged.
|
|
5955
|
+
- **I-2.** A peer with an `accepted` or `done` history entry on the task never reviews it (`implementers`), whoever owns it now, a named reviewer and the declared reviewer pool included: refusal code `policy`, reason `implemented this task`. It is the last capability/independence check in `blocked()`, before queue admission, so it only ever removes a candidate that was otherwise eligible: a former implementer that is offline, not attached, paused, failing or off roster is refused for that real reason and leaves the outcome as it was before I-2 (a task with no other reviewer still approves directly). When I-2 removes the last eligible candidate, the reviewer is the person and `reviewBlocked` is set (`every eligible reviewer implemented this task: the person reviews`); there is no fallback to an implementer. The person's own entries are not an implementation. A review-class and a PII task are unchanged.
|
|
5083
5956
|
- **Not in this slice.** Nothing records the planner: no board column, no history entry, no notice, no authority check. `Tasks` passes `planners` and reads nothing back; 525-2 owns recording and the per-task authority. A task proposed today behaves exactly as before.
|
|
5084
5957
|
|
|
5085
5958
|
## Amendment: deliverable records bound to content hashes (#509a)
|
|
@@ -5100,7 +5973,7 @@ The only artifact identity was GitHub (`repo`, `issue`, `pr`, `head`, `reviewedH
|
|
|
5100
5973
|
|
|
5101
5974
|
The second slice of #509 (AC3): a person-facing list of what tasks delivered and what a reviewer approved, and a dashboard marker. Nothing new is stored: both read the records 509a already writes.
|
|
5102
5975
|
|
|
5103
|
-
- **Command.** `ahub deliverables [--task <id>] [--approved] [--check] [--json]`
|
|
5976
|
+
- **Command.** `ahub deliverables [--task <id>] [--approved] [--check] [--json]` was the person's; since #933 it is Allowed, and an agent's list holds only the tasks `hub_task_show` would show it (#933: see the amendment "the console class keeps trust, money, attribution and terminals"). Each row is a task that has delivered files, newest task first: `#id title [delivered|approved]`, then each file's id, path (only where `publicDeliverables` allows), format, version, size and full sha256. A PII task or a `pii.paths` file shows id, hash and size only, and the title is `publicTitle`. `--task` takes one positive integer (a repeated `--task` is refused rather than keeping the last); `--approved` lists only tasks whose approval bound records, and shows those approved bytes.
|
|
5104
5977
|
- **Measured or not.** Without `--check` no file is opened: the list is the board's records, `checked` is false and no row says anything about change. With `--check` every listed file is measured now through `Tasks.deliverableStatus` (the 509a reader, with its limits and its `unknown` for a file the hub cannot vouch for), each file shows `unchanged`, `changed`, `missing` or `unknown`, and the row says `changed since review` only for a measured difference. The row's own reading (`result`) follows the same order: it says `(unchanged)` only when every file did. Consumers of `--json` read `result`: `changedSinceReview` stays false next to `result: "missing"` or `"unknown"`, because it is true only for a measured difference. `Tasks.deliverableList` is the one reader.
|
|
5105
5978
|
- **Dashboard.** The snapshot's task rows gain an optional `deliverables: {state, files, checked?}` (`Tasks.deliverableBadge`): the same public projection of the same records, never a file read, because the page polls every second and hashing up to 512 MiB per task on the poll is not acceptable. `checked: {at, result}` appears only when a live read (`task_show`, `ahub deliverables --check`) measured exactly these records; `result` is `changed`, `missing`, `unknown` or `unchanged` for the whole task, and `unchanged` only when every file measured unchanged (a change outranks a missing file, which outranks one the hub could not read, such as every file under an unverified execution root). The page and the CLI say `a file is missing` or `could not be measured` for those, never `unchanged`; `Tasks.deliverableResult` is the one reducer (the cache is keyed by the record ids and hashes, so a redelivery or a new approval clears it). The card shows a `Deliverables n` chip (with `approved` and `changed` when known); the task sheet gains a Deliverables group with each file's path (or `private file`), format, version, size and a twelve-character hash prefix, and says `not measured now: run ahub deliverables --check` when nothing was measured. `PROTOCOL` is unchanged: the field is optional and an older page ignores it.
|
|
5106
5979
|
- **Interpretation.** The issue says "a dashboard column". The dashboard's board is kanban columns by task state with no per-task table, so a new column of its own has no place; the chip on the card and the group in the task sheet are the equivalent surface. Say so in review if a table was meant.
|
|
@@ -5174,3 +6047,260 @@ Ceiling: a scan lists the board once per status revision (at most one per flush)
|
|
|
5174
6047
|
- `hub_task_show` also admits the configured planner (a peer holding the `planner` role, `Tasks.isConfiguredPlanner`, `ConductorHooks.isPlanner`) for any task outside the backlog (a staged backlog row stays hidden from agent task tools, #263, and is read through the planner tools; a peer that is also the conductor takes the audited conductor path). It gets exactly the conductor's view (`publicConductorTask` over `Tasks.publicView(task, true)`), so a PII task is still a stub with no history. The read writes no conduct audit event and grants nothing else: the planner cannot assign, escalate, hold or start through the conductor tools, and every other peer is refused with the conductor-role error as before.
|
|
5175
6048
|
- `hub_task_cancel` by the planner keeps every check it had (its own proposal, `backlog` or `proposed` state, planner authority, no private turn on ordinary work, the flow revision, recovery and shutdown exclusion) and replaces only the acceptance test with the evidence test `hub_backlog_return_untouched` uses (`taskHasWorkEvidence`): a branch, commit, PR, recorded local check, done, verdict or reopen refuses with the unchanged text `planner may cancel only its own historically unaccepted backlog or proposed work`. An accepted-then-declined task with none of them (the observed case, #454) is cancellable; the history keeps the acceptance and the decline.
|
|
5176
6049
|
- Reason: the planner could not read a task it had proposed once an owner took it, and could not clear the board of a task its owner declined untouched. The person's cancellation is unchanged.
|
|
6050
|
+
|
|
6051
|
+
## Amendment: conductor-bound recovery continuation and task unassign (task 535, #900)
|
|
6052
|
+
|
|
6053
|
+
- A configured conductor may create a restart with `ahub restart --yes`, or an upgrade with `ahub upgrade --yes --to <exact-version>`, only when the source hub can issue the operation's private continuation proof. The parser still validates `--to` as an exact version. A target that requires handoff to a newer or otherwise unsupported coordinator is unknown for this purpose and remains person-only.
|
|
6054
|
+
- The source preflights every planned project for recovery support and current authority before staging. An upgrade stages and verifies its exact target next; a restart has no package stage. Only after target bytes and feature support are verified does the source issue the bounded, fixed-shape per-operation host HMAC record, before publishing the public operation, taking its lock or applying runtime holds. Its mode-0600 key and record stay in host state, separate from public recovery JSON and `delegatedAudits`. The seal binds operation id, initiating peer, project id and canonical root, source instance, target root/package digest, plan fingerprint and a version-neutral digest of bounded raw config/routing texts, source-resolved fallback routing bytes and the fresh actor/privacy decision. It does not hash expanded defaults, default roles or parsed routing; the public `conductorRoutingDefaults` map is advisory and the target inherits the sealed source fallback. The private record stores digests rather than raw config, routing contents or the HMAC key. Upgrade package identity includes the exact hoisted `@staix/agent-mem` dependency; restart uses its verified source dependency.
|
|
6055
|
+
- The record authorizes continuity only for the same conductor and its own source-created restart or authorized upgrade. Another conductor cannot adopt it, and a public or person-created recovery operation cannot be converted into conductor authority. The person retains the ordinary recovery path. `recovery resume|abort` may continue only that same bound operation; `recovery-run` is not a general conductor command.
|
|
6056
|
+
- A detached runner or target launcher may continue only the operation's captured, validated argv. No arbitrary CLI command, target selection, fresh-session override or other console authority is conveyed. `--live-test` is a separate existing invocation gate and does not issue this proof.
|
|
6057
|
+
- The host rechecks the initiating peer's current conductor role and roster membership, project/write authority, and non-PII/privacy policy before every effect and after awaits. Offline continuation is limited to bounded current actor/privacy evidence; unrelated public board writes alone do not invalidate it. Missing, changed or unreadable evidence stops the next effect before it occurs.
|
|
6058
|
+
- Captured TUI terminal restoration remains person-only: a delayed native launcher cannot re-prove the conductor at its eventual spawn. Conductor continuation refuses captured terminal launches; headless restoration uses the target-owned snapshot through a tools connection.
|
|
6059
|
+
- An unmanaged reconnect-only Claude channel may reattach itself without a native launch. The source keeps a SHA-256 fingerprint of its observed session identity separate from restoration metadata (the raw unmanaged session id is omitted from the plan) and verifies the exact peer, kind and attachment against the live channel. This exception requires no terminal or restoration arguments, no managed launch proof, and an absent or confirmed-ended launcher; unknown launcher evidence refuses. Other peer identities still compare exactly.
|
|
6060
|
+
- Recovery does not adopt unpublished protected config or routing changes. Candidate policy must match the source's accepted running policy. The replacement validates the exact captured startup inputs against the private grant before adapter imports and uses those same inputs for config and routing publication; a later file edit cannot replace the verified snapshot. Changed or unknown policy requires the person.
|
|
6061
|
+
- The proof is a same-host integrity mechanism, not an OS boundary: a process able to replace both the host key and its private record can replace the seal. Native and real-account receipts are not implied by fixture tests.
|
|
6062
|
+
- `task unassign` is also a conductor operation, executed and recorded as the actual conductor. It does not route a replacement or stop native work. An owner unassign keeps automatic offers suppressed until a later authorized explicit assignment or reservation clears that suppression; other task eligibility and planner rules are unchanged. Open tasks with host-marked conductor assignment history require a `conductor_recovery` target even for person-run recovery, so a downgrade cannot erase manual intent.
|
|
6063
|
+
- Ordinary peer classifications do not expand: `backlog list`, `release list` and `sdlc costs|metrics` remain Console for agents; existing public read forms and dry-run previews are unchanged.
|
|
6064
|
+
|
|
6065
|
+
## Accepted contract: external module-kind instances (#754)
|
|
6066
|
+
|
|
6067
|
+
**Status: accepted on 2026-10-27.** The [person's decision on #754](https://github.com/STAIxBWLB/agent-hub/issues/754#issuecomment-6441781951) selects O1 explicit module opt-in, O2 implementer/verifier/ordinary continuing reviewer, O3 live startup catalog only and O4 retained live-module recovery refusal. The contract-only deliverable was board task #287 (PR #892), against main `7a599197`; it enabled no source or feature. Implementation is board task #529, with field names frozen below and A1-A8 as its delivery gate. The original A4 blanket instance refusal applies to earlier hosts; this implementation replaces it only for opted-in, host-verified instances. Source/fixture support is distinct from native/account receipts.
|
|
6068
|
+
|
|
6069
|
+
### Existing boundary and accepted scope
|
|
6070
|
+
|
|
6071
|
+
- Keep person-owned, untracked machine-local bundle declarations, content pins, daemon-startup-only imports and immutable project catalogs. CLI reads, previews, reloads and watchers never evaluate a module. A tracked, shared, cloned or unknown-provenance declaration grants nothing.
|
|
6072
|
+
- Keep the A4 executable subset: headless ACP, the public `AcpPeer` transport, bound peer id, `deliveryOnly === true`, `hubOwned === true`, `form === "headless"`, verified owned-group admission and load-session echo. A module pin authorizes daemon-privileged code; it is not a sandbox or a proof that an approved module cannot be malicious. An import timeout is not cancellation or rollback of privileged initialization.
|
|
6073
|
+
- Keep builtin namespace reservations and every fixed-builtin capability exclusion. No native engine, native/fresh-reviewer variant, host proof port, native home/model/provider selector, native hook, budget callback or arbitrary configuration callback becomes an external-instance capability. `instances` policies reserved for builtin definitions do not become module-supplied callbacks.
|
|
6074
|
+
- The accepted first slice requires explicit module opt-in and permits implementer, verifier and ordinary continuing reviewer instances within an explicit module-role subset and the kind ceiling. Every instance has its own managed worktree. Existing reviewer independence and MCP restrictions apply; planner, conductor and fresh/isolated reviewer capabilities remain excluded, with no claim of fresh-reviewer isolation. Semantic validation uses the live startup catalog, and live external controlled recovery remains refused.
|
|
6075
|
+
|
|
6076
|
+
### Stable module identity and a current startup binding
|
|
6077
|
+
|
|
6078
|
+
- A kind name, display label, instance suffix, roster row or adapter-reported generation is not module identity. Startup retains the provenance of each validated definition alongside its captured bundle: canonical project root, normalized project-relative declaration path, full lowercase SHA-256, exported kind, `ADAPTER_API` and a digest of the validated data-only capability/permission ceiling. Duplicate kinds, builtin/configured namespace collisions, wrong hash/API/kind, unsupported fields and unknown provenance refuse independently before any affected native start or bus admission.
|
|
6079
|
+
- Resolve `instances.<id>.kind` only against that daemon's verified startup catalog. Its module binding is the exact retained tuple, not a later path lookup or an entry from the process-global builtin registry. No kind is inferred from an id prefix to repair a missing declaration, and no missing external kind falls back to a builtin, configured kind or another module with the same label.
|
|
6080
|
+
- Keep stable identity separate from liveness: a new daemon has a new catalog epoch and new owner generations. Recovery may compare an exact stable tuple, but it never reuses an old catalog epoch as current-owner proof. Pin, declaration-path, exported-policy or instance-policy changes invalidate continuity; no automatic repin, path relocation, native-session rebinding or fresh-session fallback is implied.
|
|
6081
|
+
- Module and instance declarations remain restart-bound. A reload can report pending changes and revoke affected authority when its evidence no longer matches, but cannot import, repin, create a new catalog or silently publish the desired replacement policy as running policy.
|
|
6082
|
+
|
|
6083
|
+
### Implementation field and schema names (#529)
|
|
6084
|
+
|
|
6085
|
+
- Module opt-in is `AdapterDefinition.headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] }`. Only own-data values within the module's roles are admitted. Raw builtin `instances` policy remains forbidden by presence; the host derives its internal headless policy after validation.
|
|
6086
|
+
- `CreateContext.instanceStateDir?: string` carries the private host-derived namespace at `<stateDir>/instances/<id>`; `stateDir` remains canonical daemon control/MCP state. An instance factory must pass the exact host `daemon.acp` options object to the public `AcpPeer`; copying or replacing those options cannot mint a managed-spawn receipt. MCP children receive a fresh launch id and remain mutation-fenced until owned startup admission completes.
|
|
6087
|
+
- `ExternalInstanceBinding` schema 1 has exactly `schema`, `projectRoot`, `instance`, `kind`, `modulePath`, `sha256`, `api`, `capabilityDigest`, `policyDigest` and `executionCwd`. Paths and module pin are captured at verified startup, digests cover normalized data-only capability and instance policy. Recovery peers and restart snapshots carry it as `externalInstance`; integrity includes `externalBindingsDigest`. A private `external-instance.json` contains `{ externalInstance, sessionId }` for later same-binding manual resume. A supplied session id requires an exact retained binding; a mismatch refuses before native effects. After restarting to capture changed declarations, a person may instead request a fresh start under the current verified binding, and successful admission replaces the record. A fresh native session never applies instance-policy edits to an already running daemon. These records carry no runtime permission choice or current-owner proof.
|
|
6088
|
+
- The combined feature is `external_adapter_instances`, requiring `external_adapter_modules` and `peer_instances` even for stopped declarations. The bounded data-only status projection is `externalInstanceCatalog` schema 1, scoped by canonical `projectRoot`, daemon `epoch`, policy `fingerprint` and at most 64 instance rows. Authenticated `ControlClient.readExternalInstanceCatalog(projectRoot)` validates its live epoch and features; it never creates executable definitions. Offline declarations remain separate `declaredExternalInstances` rows with fixed `catalog-unavailable` refusal codes. A kind missing from the live startup catalog stays inert with `kind-unavailable`: the host cannot distinguish a misspelled builtin from a refused module kind without guessing its identity.
|
|
6089
|
+
|
|
6090
|
+
### Instance declaration and opt-in policy
|
|
6091
|
+
|
|
6092
|
+
- Instance ids retain the existing `<declared-kind>-<name>` grammar, nonreserved ASCII id bound of 32 characters and project-wide maximum of 64 instances; modules retain their 16-entry bound. A kind too long to admit a valid instance id refuses; ids are never truncated or rebound. Existing builtin/configured declarations keep their current rules.
|
|
6093
|
+
- Under accepted decision O1, a newly pinned module explicitly declares a bounded own-data headless-instance opt-in. Its proposed logical fields are headless form and an allowed role subset. This is a new reviewed external data subset, not permission to declare builtin `instances`, reviewer backends or executable policy callbacks. Field/API compatibility names must be frozen in the separate implementation task plan; older hosts must clearly refuse the new subset rather than treat it as singleton support.
|
|
6094
|
+
- An instance declares only its kind, role subset, headless form, managed worktree and the existing permission/read-only selection. Command, argv, provider, model, native data home, environment overrides, launch callbacks and fresh review mode are outside the first slice. The host must newly derive a private per-instance state namespace and pass it with the exact instance id, execution root and MCP binding. Today the ACP context carries the shared daemon `stateDir`; it does not already provide that isolation. Keep canonical daemon control/MCP state separate from private instance state, and freeze the context field carrying the new namespace before implementation; config cannot choose a callback or alternate SDK.
|
|
6095
|
+
- Roles are intersected with the captured kind ceiling, the accepted instance-policy ceiling and host role rules at config validation, effective-role derivation, routing and tool admission. A later id-keyed role list cannot restore a refused role. Conductor and builtin-only planner eligibility stay excluded. Accepted O2 includes ordinary continuing reviewer instances alongside implementers and verifiers, only under the explicit module-role subset and existing reviewer-pool independence and MCP restrictions. An external reviewer instance never reviews a task it owns or implemented; the current reviewer-pool family and same-instance rules apply unchanged, as for builtin reviewers. Fresh mode and isolated builtin reviewer capabilities remain refused; every reviewer instance has its own worktree, without any claim of fresh-reviewer isolation.
|
|
6096
|
+
|
|
6097
|
+
### Execution root and non-transferable current-owner proof
|
|
6098
|
+
|
|
6099
|
+
- Every external instance, including a verifier, names its own existing `.worktrees/<id>` directory. This is intentionally stricter than today's generic verifier fallback. The host checks real directory identity, exact registered worktree root, same Git common directory and non-prunable inventory; no creation, repair, another checkout or canonical-root fallback occurs. A potentially writing instance requires an attached branch. Detached read-only execution needs an explicitly accepted read-only policy and cannot be used as write-owner evidence.
|
|
6100
|
+
- Before native spawn, freeze the module/instance binding and the validated cwd. An affected instance joins the bus or gains `ownerIsolation` only after the host has current evidence tying that exact runtime object and owned-child launch generation to that cwd, module tuple, instance policy and daemon/catalog epoch. A native session or MCP environment string alone cannot establish it.
|
|
6101
|
+
- The implementation must obtain that receipt from the actual managed ACP spawn/owned-process path, through a host-retained non-virtual binding, not a module's `recoveryMetadata().launch.cwd`, arbitrary getter or returned "proven" flag. Bypassing the managed spawn, returning the wrong transport/id/options or failing owned-group verification leaves no admissible receipt and refuses instance admission. A receipt describes the managed runtime; it does not confine daemon-privileged module code or all native tools to a filesystem sandbox.
|
|
6102
|
+
- Recheck current bus owner, child generation, startup binding, policy and the registered worktree before write/task-owner admission, after intervening awaits and immediately before effects. Replacing a directory, switching the attached branch inside its worktree (including a switch to another attached branch), changing the module/policy binding, losing the owned process or receiving unreadable evidence revokes authority. A branch switch requires an explicit stop and person start to bind the new branch; a commit advancing the same branch does not. An unavailable check is unknown, never isolation; retain a failed-start owner with unconfirmed processes for explicit teardown rather than assigning its id to a replacement.
|
|
6103
|
+
- Give every instance its own host records, queue/task identity, native session binding and MCP identity. Child environments replace inherited agent markers with the exact instance id. MCP access requires a host-issued current launch binding, rechecked after awaits; an old child, a base-kind child or a client that echoes another instance's env cannot act for it. Native configuration/credential-home isolation is not implied by worktree isolation, and no automatic native-home copying or credential migration is introduced.
|
|
6104
|
+
|
|
6105
|
+
### Permission ceilings and lifecycle
|
|
6106
|
+
|
|
6107
|
+
- A per-instance choice is machine-local and no wider than the captured module kind's native mapping and accepted host ceiling. An explicit managed mode must be advertised and acknowledged by that native session before bus admission. Missing mapping, failed acknowledgement or unknown state refuses that selection; it never becomes an implicit allow or a falsely claimed native `ask`. An omitted choice may retain the kind's existing unmanaged behavior, visibly unmanaged.
|
|
6108
|
+
- `readOnly: true` requires the kind's supported native read-only mapping and observed acknowledgement. It also applies the existing host ACP read/search/think restriction and read-only MCP/tool ceiling; neither confirmation, an allow-always choice nor session budgets raise it. Read-only and an independent `permission_mode` selection stay mutually exclusive. Neither read-only acknowledgement nor a pin claims kernel isolation.
|
|
6109
|
+
- Roster removal first fences dispatch, current tool authority and approvals, then stops the instance through the verified headless owned-process path. It does not detach a person's TUI or widen an external kind to a terminal form. Offline is insufficient if an owned process is pending or its absence is unknown: attempt only verified owned teardown, retain the owner/queue/in-flight uncertainty on failure, and perform no permission reset, task handoff, automatic restart or replacement until teardown is confirmed.
|
|
6110
|
+
- A successful removal preserves queued work, independent pauses and recorded history. Return to the roster does not itself start or resume a module instance. A later explicit start obtains a new owner/launch proof under the same accepted identity rules; stale tools and replies never revive a retired generation.
|
|
6111
|
+
|
|
6112
|
+
### Bounded CLI metadata without code evaluation
|
|
6113
|
+
|
|
6114
|
+
- The current `{kind, displayName}` metadata is insufficient for external-instance semantic validation. A supporting daemon must project a separate data-only view of its verified catalog binding and instance policy. Do not manufacture `start.create`, `instances` callbacks or executable `AdapterDefinition` values from display metadata to make a validator pass.
|
|
6115
|
+
- Proposed public view fields are bounded instance id, kind, display name, role names, headless/read-only and permission-state enums, availability/refusal code and an observation epoch/fingerprint. Keep at most 64 instance rows, kind/id at 32 characters, display names at 256 UTF-8 bytes and fixed refusal codes; truncate display only with an explicit incomplete indication, never silently validate a truncated inventory. Full pins/declaration provenance belong in authenticated person diagnostics. Module bodies, callback source, argv, native ids, provider errors, secrets and arbitrary native text are excluded.
|
|
6116
|
+
- `status`, `help`, `route explain`, previews and upgrade planning consume declarations plus that bounded host projection without importing modules or invoking module callbacks. Metadata explains observed state; it cannot register code, grant permissions, prove current ownership or authorize a start. A mutating/start request revalidates the current authenticated daemon instance, feature set, startup tuple and declaration revision, not metadata echoed by its caller.
|
|
6117
|
+
- Accepted O3 requires live-catalog semantic validation and keeps module declarations as `{path, sha256}`. Without a matching daemon, raw declarations may be listed as declared/unknown but cannot be resolved to an executable instance, assigned a guessed ceiling or used to prepare recovery. A cached projection never becomes runtime authority merely because its hash or label matches.
|
|
6118
|
+
|
|
6119
|
+
### Recovery compatibility and refusal before effects
|
|
6120
|
+
|
|
6121
|
+
- The implementation advertises daemon feature `external_adapter_instances`, in addition to the existing `external_adapter_modules` and `peer_instances`. Retained receipts and restart snapshots must carry a versioned stable binding containing canonical project root, instance id/kind, normalized module declaration path, full pin, API version, capability and instance-policy digests, and validated execution root. Integrity validation covers every field; any recorded native session is bound to that same tuple. Catalog epoch, runtime object identity and launch generation remain live host evidence, never portable ownership proof. Freeze the serialized schema in the separate implementation task plan and add the feature to every source/target incompatibility preflight. Old metadata cannot be silently read as that new proof.
|
|
6122
|
+
- A supporting coordinator/source must refuse a target missing any required feature before recovery holds, session capture, native stop, terminal close, commit, target startup or module evaluation for that operation. This applies even when the affected instances are offline: a target that cannot interpret their declarations/bindings is not compatible. Apply the same rule to reused staged targets, lease renewal and the last pre-commit read; once committed, retain the verified target/package binding rather than rediscovering identity from mutable config.
|
|
6123
|
+
- A source lacking the combined feature, an unreadable source feature/inventory response, or a declaration with no verified kind-to-pin binding also refuses before lifecycle effects. An older source's unknown/all-offline rows cannot waive the new external-instance floor. The CLI may conservatively report a possible unresolved module instance to refuse; that is not permission to invent its identity. Existing builtin/configured offline exceptions remain unchanged for those declarations.
|
|
6124
|
+
- The staged target's host features/SDK and captured declaration pins are checked without importing project modules. A supporting target later validates the same captured module tuple and policy before admitting any affected instance; changed/missing pin, incompatible API/policy, wrong project/root or incomplete retained binding refuses without reusing a session under another identity. Module evaluation already authorized at daemon startup is not rolled back; a post-import exported-kind mismatch must still prevent native spawn, bus admission and owner proof.
|
|
6125
|
+
- Accepted O4 retains A4's live-module controlled-recovery refusal. Stop affected module instances explicitly before the supported host restart/upgrade path; keep their stopped records and restore builtin peers normally. External instances remain stopped until a person's explicit start with the exact verified module/instance binding. A supplied recorded native session id is bounded and must receive a matching load-session echo; absent proof refuses and never falls back fresh. Automatic external-instance resume would require separately accepted source/target native-session, process-group and restoration receipts, not inference from `resumeById`.
|
|
6126
|
+
- Feature-aware binaries (and any explicitly designated compatibility backport) must reject unsupported module-instance declarations before project-module evaluation or native start. Already released A4 binaries cannot provide this guarantee: at baseline `7a599197`, daemon startup imports pinned modules before validating instance declarations, so direct execution can evaluate a module and then refuse the instance. A supporting coordinator must refuse that target before starting it; direct legacy execution remains an explicit migration limitation, not a promised safe path. No client attaches an instance id without the combined feature. Removing a declaration to cross an initial unsupported-source upgrade is an explicit person action with stopped/history evidence, never an automatic workaround; a backport/version floor requires separate acceptance.
|
|
6127
|
+
|
|
6128
|
+
### Acceptance matrix for the later implementation
|
|
6129
|
+
|
|
6130
|
+
These are the implementation delivery requirements. The earlier contract-only PR #892 did not run them or enable support; #529 reports their exact-head results separately. Use fake bundled modules, fake ACP agents, isolated homes and host-side effect counters first; real native concurrency/session restoration needs its own declared receipt.
|
|
6131
|
+
|
|
6132
|
+
| Claim | Required positive and negative proof |
|
|
6133
|
+
|---|---|
|
|
6134
|
+
| A1 mixed catalog/identity | A builtin extra instance and implementer, verifier and ordinary continuing reviewer instances of an opted-in external kind coexist with distinct roots, native sessions, records, queues and MCP identities. The reviewer is admitted only within the explicit module-role subset and existing reviewer independence/MCP restrictions, with its own worktree and fresh mode refused. Missing opt-in, duplicate/colliding kinds, kind/pin/API/policy changes, ambiguous namespace and malformed/over-limit declarations refuse without rebinding. |
|
|
6135
|
+
| A2 current execution owner | Registered exact worktrees succeed. Missing, symlink-replaced, prunable, foreign-repository, canonical-fallback and detached-writer roots refuse. Swap the root/pin/policy or owner across each await; no stale write/owner admission occurs. A factory that lies in metadata or bypasses managed spawn cannot mint owner proof. |
|
|
6136
|
+
| A3 policy ceiling | Implementer, verifier and ordinary continuing reviewer roles and id-keyed overrides cannot exceed the explicit module-role subset or accepted kind/instance ceiling. Continuing reviewers cannot review tasks they own or implemented; existing reviewer-pool family, same-instance and MCP restrictions apply unchanged, and fresh mode is refused and planner/conductor remain excluded. Missing or unacknowledged native modes, read-only escalation and mutating MCP operations refuse before effects. Builtin-only fields, fresh reviewers, TUI/native engines, native proof ports and config callbacks remain refused, including explicit undefined declarations where presence is forbidden. |
|
|
6137
|
+
| A4 no-import CLI | With import/callback tripwires, exercise online and offline `status`, `help`, `route explain`, preview and upgrade planning. No module evaluation occurs; unknown/stale/foreign-epoch/incomplete metadata cannot enable a factory, guess a ceiling or grant ownership. Outputs respect all row/byte bounds and contain no argv/provider/source data. |
|
|
6138
|
+
| A5 roster and process lifetime | Fence an active delivery and approval before stop. Repeat with offline-pending and unknown owned groups: no handoff/reset/replacement until confirmed gone; queued and uncertain work is retained. Return and stale MCP/reply attempts cannot revive an old launch. |
|
|
6139
|
+
| A6 source/target compatibility | Test missing features on source and target, unknown/all-offline old rows, retained/reused target mismatch, changed pins/API/root/policy and feature-aware/backported binaries reading unsupported config. Counter assertions show zero holds/captures/stops/closes/commits/new imports/native starts at a supporting preflight refusal. Separately cover the unpatched A4 startup order with a harmless import tripwire: it may evaluate the pin before refusing the instance, and must not be represented as satisfying pre-import refusal. Legacy builtin/configured recovery remains byte/behavior compatible. |
|
|
6140
|
+
| A7 supported recovery ceiling | Under accepted O4, live external peers refuse controlled recovery; explicitly stopped instances stay stopped while compatible builtin recovery completes. Manual same-binding session load requires exact echo; missing/foreign echo and stale native ids refuse with no fresh fallback. Automatic resume is outside the accepted first slice; any later extension needs separate acceptance and its additional receipt matrix. |
|
|
6141
|
+
| A8 delivery gate | Amend the relevant source/target feature checks and docs in the implementation PR; focused mixed regressions, conformance, affected seeded guards and the full `scripts/check.sh` pass at its head with `check: OK`, as required by issue #754 AC5. The earlier docs-only contract PR used documentation checks; that evidence does not satisfy this implementation gate. Report native/account checks and unknown coverage separately from fake/local/hosted evidence. |
|
|
6142
|
+
|
|
6143
|
+
### Decisions
|
|
6144
|
+
|
|
6145
|
+
The [person accepted these choices on 2026-10-27](https://github.com/STAIxBWLB/agent-hub/issues/754#issuecomment-6441781951). The acceptance authorized the separate implementation task #529 under the required proofs; it enabled no source behavior by itself.
|
|
6146
|
+
|
|
6147
|
+
| Decision | Accepted first-slice choice |
|
|
6148
|
+
|---|---|
|
|
6149
|
+
| O1 opt-in authority | Require an explicit bounded headless-instance policy in a newly pinned module; an existing singleton module does not acquire multiple ids by config alone. |
|
|
6150
|
+
| O2 initial roles | Implementer, verifier and ordinary continuing reviewer within an explicit module-role subset and the kind ceiling, with existing independence and MCP restrictions. Every instance has its own worktree. Planner, conductor and fresh/isolated reviewer capabilities stay excluded; no fresh-reviewer isolation is claimed. |
|
|
6151
|
+
| O3 offline declaration binding | Keep module declarations as `{path, sha256}` and require the matching authenticated live startup catalog for semantic instance resolution; offline rows remain declared/unknown. |
|
|
6152
|
+
| O4 controlled recovery scope | Preserve the live-module refusal. Stop the instance explicitly, then a person starts it again with the same binding; there is no automatic resume. |
|
|
6153
|
+
|
|
6154
|
+
## Queue backpressure for automatic assignment (#800)
|
|
6155
|
+
|
|
6156
|
+
`tasks.queue_backpressure` is an opt-in machine-local config block. It does not
|
|
6157
|
+
change the shipped routing template or frozen routing corpus. Its independently
|
|
6158
|
+
optional integer fields are `max_queued` (0..10,000 envelopes) and `max_oldest_s`
|
|
6159
|
+
(0..604,800 seconds). Each defaults to zero, which disables that threshold. Trusted
|
|
6160
|
+
config layers merge these fields independently. A committed project cannot enable
|
|
6161
|
+
the policy, and the person-only settings controls write untracked local config.
|
|
6162
|
+
Reload applies the policy live. Zero disables a guard, so these controls have no
|
|
6163
|
+
conductor-editable permissive floor.
|
|
6164
|
+
|
|
6165
|
+
The guard applies only to new automatic owner/reviewer offers. Counts strictly
|
|
6166
|
+
above the configured maximum hold an offer; equality is allowed. An oldest pending
|
|
6167
|
+
age strictly above its threshold also holds. Missing, invalid, future or otherwise
|
|
6168
|
+
unproven age holds when age policy is enabled, rather than becoming zero or fresh.
|
|
6169
|
+
A verified empty queue has no pending age and is allowed. Unknown count holds when
|
|
6170
|
+
a queue policy is enabled. The fixed reasons are `queue-count`, `queue-age` and
|
|
6171
|
+
`queue-age-unknown`. A queue-blocked automatic reviewer walk never turns into direct
|
|
6172
|
+
approval: it remains blocked for a person or a later eligible reviewer.
|
|
6173
|
+
|
|
6174
|
+
The pure `assign()` receives policy, pending metadata and one host clock as data.
|
|
6175
|
+
`Tasks` uses the same inputs for real assignment and `ahub route explain`; routing
|
|
6176
|
+
never reads a journal or clock itself for this guard. The guard does not replace
|
|
6177
|
+
WIP, capability, privacy, independence, roster or runtime ownership admission.
|
|
6178
|
+
Planner-pull reservations still count in their own WIP metric, not as queue work.
|
|
6179
|
+
When both thresholds are off, routing results/traces and delivery behavior stay
|
|
6180
|
+
unchanged, and no queue metadata query is added to routing.
|
|
6181
|
+
|
|
6182
|
+
Only an explicit person/current single-conductor nomination can bypass queue
|
|
6183
|
+
pressure, and only for that nominated owner or reviewer role. An explicit reserved
|
|
6184
|
+
pull may bypass the owner's queue guard; an ordinary planner pull, an unreserved
|
|
6185
|
+
pull choosing its owner automatically, or HUB auto-pull on a planner's behalf may
|
|
6186
|
+
not. Merely having `namedOwner`, `namedReviewer` or a reservation is not authority.
|
|
6187
|
+
A self-claim delivers no owner offer and skips owner queue admission only. A
|
|
6188
|
+
person reservation holds its target under pressure instead of rerouting it.
|
|
6189
|
+
Override authority and all other admission conditions are rechecked after awaits.
|
|
6190
|
+
An owner nomination does not waive an automatically chosen reviewer's guard.
|
|
6191
|
+
Future automatic offers reevaluate current queue conditions; no lasting override
|
|
6192
|
+
is inferred from free-text history.
|
|
6193
|
+
|
|
6194
|
+
`Bus.assignmentQueue()` matches the number of envelopes actually waiting in
|
|
6195
|
+
`Bus.queued`, not the number of digest batches, active deliveries or WIP tasks.
|
|
6196
|
+
Age comes from host queue-arrival provenance, not an envelope sender's timestamp.
|
|
6197
|
+
Opt-in arrival metadata is bounded and persisted with the existing v1 bus snapshot;
|
|
6198
|
+
default-off snapshots keep their old shape. Legacy, missing or recovery-deferred
|
|
6199
|
+
arrival provenance remains unknown across resaves/restarts until those entries
|
|
6200
|
+
leave the queue. Advisory metadata may be omitted at the existing snapshot byte
|
|
6201
|
+
ceiling rather than refusing an otherwise valid delivery.
|
|
6202
|
+
|
|
6203
|
+
When tracking is enabled, the journal keeps small per-peer summaries committed
|
|
6204
|
+
with the bus snapshot; disabled writes do not rewrite the summary rows. Reads
|
|
6205
|
+
use summary/count/oldest/revision metadata only, never delivery body JSON or a sort
|
|
6206
|
+
of full delivery history. A revision mismatch, including an older daemon writing
|
|
6207
|
+
the snapshot without updating summaries, produces unknown age. Summaries are also
|
|
6208
|
+
checked against the bus's exact current queue count. Existing queue/delivery
|
|
6209
|
+
ordering, steering, withdrawals, settlement and recovery traffic are not changed.
|
|
6210
|
+
|
|
6211
|
+
Queue-origin holds are reconsidered after queue mutations commit, on relevant peer
|
|
6212
|
+
state/policy changes and at bootstrap. Rechecks are coalesced and bounded; they do
|
|
6213
|
+
not poll delivery history or reassign unrelated legacy/person-unassigned work.
|
|
6214
|
+
Ownerless proposals, including automatic decline/budget reroutes, are identified
|
|
6215
|
+
by their host-written root queue stall code. A planning revision triggers fresh
|
|
6216
|
+
admission rather than discarding that origin. A refusal from a separate plan or
|
|
6217
|
+
policy gate replaces the queue origin, so queue mutations cannot poll GitHub for
|
|
6218
|
+
plan evidence. Reserved retries pass through the existing WIP/in-flight-slot
|
|
6219
|
+
admission; a full slot hands readiness back to the existing WIP release path. A held named proposal keeps its
|
|
6220
|
+
intended target. Continuing reviewers stay bound unless that reviewer is actually
|
|
6221
|
+
queue-refused (or the existing reviewer-pool rules require selection). Automatic review holds carry a host-only `queueReviewHeld` history marker,
|
|
6222
|
+
bound to the current review role boundary and an opaque SHA-256 of the selected
|
|
6223
|
+
head/repository/PR identity; manual reviewer or artifact changes invalidate it.
|
|
6224
|
+
Candidate pages use board metadata, with fresh role/head/privacy/admission checks
|
|
6225
|
+
before effects. Shutdown/recovery cancels queued recheck callbacks and fences awaited passes.
|
|
6226
|
+
|
|
6227
|
+
Status exposes policy and bounded per-peer queued count, host oldest timestamp,
|
|
6228
|
+
age and fixed blocked reason separately from WIP. Unknown numeric values are null,
|
|
6229
|
+
and human output says unknown. With the policy on, an unknown host age does not
|
|
6230
|
+
fall back to a sender timestamp and appear fresh. The stall view retains the same
|
|
6231
|
+
queue reason codes; metrics contain ids/numbers/closed words, not envelope bodies
|
|
6232
|
+
or task text. The policy changes assignment admission only: it never drops,
|
|
6233
|
+
reorders, auto-resolves or cancels existing delivery.
|
|
6234
|
+
|
|
6235
|
+
## Vendor model and effort controls (#266 AC1)
|
|
6236
|
+
|
|
6237
|
+
This is the capability snapshot from 2026-10-29, for Codex 0.165.0, Claude Code
|
|
6238
|
+
2.1.312 and Kimi 2.3.2. It precedes task/role `exec`, execution policy and bench
|
|
6239
|
+
matrix implementation. The [sanitized receipt](../verification/2026-10-29-model-effort-capabilities.md)
|
|
6240
|
+
separates native configuration/control acceptance from provider execution.
|
|
6241
|
+
A successful setter or readback does not identify the model that served an answer.
|
|
6242
|
+
|
|
6243
|
+
| Vendor / setting | Launch | After launch | Verified boundary and remaining unknown |
|
|
6244
|
+
| --- | --- | --- | --- |
|
|
6245
|
+
| Codex model | `--model` advertised by help; the `model` config override has verified native readback | `thread/start` / `thread/resume` model override and `turn/start.model`; per-turn setting becomes a thread default | A bounded, network-denied turn attempt followed by interrupt and no-override resume returned the requested replacement model. Provider execution is **UNVERIFIED**. |
|
|
6246
|
+
| Codex effort | `model_reasoning_effort` config override; native config readback verified | Thread config override and `turn/start.effort`; sticky across later turns | Native reasoning-effort readback changed and persisted through the same interrupted attempt. Effort is a nonempty string in this version's schema; eligible values come from each model's `supportedReasoningEfforts`, not a universal four-level enum. Provider application is **UNVERIFIED**. |
|
|
6247
|
+
| Claude Code model | `--model`; accepted by the isolated print/control session | Session `set_model` control ACK and canonical-family readback verified; `/model` is also documented for the terminal | Session configuration, not an active inference switch. Provider execution and a real interactive/account leg are **UNVERIFIED**. |
|
|
6248
|
+
| Claude Code effort | `--effort`; launch-level status readback verified | `/effort` changes the session setting for a later message; print-session set/status readback verified | Native status changed from the launch choice to the new choice. Model-specific effort support and actual provider application are **UNVERIFIED**; thinking on/off or a thinking budget is not interchangeable with graded effort. |
|
|
6249
|
+
| Kimi model | `--model` is advertised for that invocation | Vendor documents `session/set_model` and `session/set_config_option` with `configId: model` | Native initialize succeeded, but `session/new` returned `-32000` in the isolated, unconfigured, network-denied fixture. Selection ACK/readback and provider execution are **UNVERIFIED**, not established unsupported. |
|
|
6250
|
+
| Kimi effort | No graded-effort flag was advertised by the observed root help; config-driven launch effort is **UNVERIFIED** | Vendor documents a `thinking` config picker; graded ACP effort is **UNVERIFIED** | Do not equate an on/off picker, permission `session/set_mode`, or an arbitrary graded string with supported effort. An open upstream report describes a graded-to-boolean collapse; revalidate the installed model/options before any mapping. |
|
|
6251
|
+
|
|
6252
|
+
The native interfaces and the hub's current wiring are different contracts. At
|
|
6253
|
+
`88e38f3d`, the Codex adapter forwards TUI fields but hub-created turns supply no
|
|
6254
|
+
model/effort override. Claude's headless writer supplies its declared model when
|
|
6255
|
+
launching each child, including resumes; its current transport closes stdin after
|
|
6256
|
+
input and exposes no later setting control. Kimi's ACP path has a launch model
|
|
6257
|
+
argument and permission-mode control, with no hub model/effort setter or selected
|
|
6258
|
+
model readback. These observations add no runtime setting or permission change.
|
|
6259
|
+
|
|
6260
|
+
The [Codex app-server contract](https://learn.chatgpt.com/docs/app-server),
|
|
6261
|
+
[Claude model/effort controls](https://code.claude.com/docs/en/model-config),
|
|
6262
|
+
[Kimi ACP coverage](https://moonshotai.github.io/kimi-code/en/reference/kimi-acp)
|
|
6263
|
+
and [graded ACP effort report](https://github.com/MoonshotAI/kimi-code/issues/1486)
|
|
6264
|
+
corroborate the surface descriptions; moving documentation does not extend this
|
|
6265
|
+
receipt to another native version. `turn/steer` is not the Codex setting interface.
|
|
6266
|
+
An unsupported or unverified mapping must remain unavailable to a future policy,
|
|
6267
|
+
rather than silently retaining a default or treating unknown as supported.
|
|
6268
|
+
The [remaining smoke legs](../smoke.md#model-and-effort-capability-verification-266-ac1)
|
|
6269
|
+
are separate from AC2-AC10 and any real matrix, account or provider proof.
|
|
6270
|
+
## Amendment: domain packs (#513a)
|
|
6271
|
+
|
|
6272
|
+
A domain pack bundles a profile with the roles it expects, a review rubric and example checks in one file, so a project adopts a way of working with one command. This amendment records where the shipped implementation differs from the first plan for #318, and why.
|
|
6273
|
+
|
|
6274
|
+
- Format. `<name>.pack.json` holds exactly `name` (equal to the file name, never a profile's name), `profile` (one of the four shipped profiles), `summary`, `roles` (at most 8 catalog role ids), `rubric` (1 to 12 criteria, each `id`, `text` and `required`; ids are the #507 criterion ids) and `checks` (at most 8, each `id` and a display `note`). Any other field is refused. A pack is data and never authority: it adds nothing to `checks`, selects no command and grants no permission.
|
|
6275
|
+
- Checks carry a `note`, not a `command`. The first plan gave a check a display command string. A command-shaped field in a file that `ahub init` writes into the project invites reading it as runnable, and `example_checks` is already the display surface: an example check is advisory text that is never selected or run. The pack's checks therefore become `profile_overrides.example_checks` as `<id>: <note>`. The deterministic check scripts that run are #513b's, and they need their own reviewed way to be selected, since profile selection installs no command (#259).
|
|
6276
|
+
- Roles are named, not created. The pack lists catalog role ids that must already exist (shipped, or a project `.agenthub/roles/<id>.md`); a pack naming an unknown role is refused whole. `ahub init --profile <pack>` previews and writes the project's config, and does not copy role files: the project assigns roles with `catalog_roles` as before. The first plan had init install role files; that would make the pack a second writer of the role catalog, which has one loader and one validator.
|
|
6277
|
+
- Layers. A shipped layer (`src/hub/shipped-packs.ts`, files in `templates/packs/`, bundled as text like the shipped roles) and a project layer (`.agenthub/roles/<name>.pack.json`, in the directory the role catalog reads; the catalog takes `.md` only). A project pack of the same name replaces a shipped one. Only the format example ships in #513a; the writing, analysis and ops packs are #513b.
|
|
6278
|
+
- Init. `--profile` takes a profile or a pack name; a pack's base profile must equal the project's existing profile, and existing `profile_overrides` that differ from the pack's (compared as data, with keys sorted, so key order never matters) are refused rather than overwritten. The preview names a pack by ids only (rubric ids, check ids, role ids), never its text. The apply writes the rubric as `profile_overrides.review_checklist` (each line `<id>: <text>`, optional criteria marked, then the `{check}` line) and the checks as `profile_overrides.example_checks`, keeping every other key of the config; applying the same pack again changes nothing.
|
|
6279
|
+
- Rubric ids follow the live #507 ids (a letter, then letters, digits or `-`, at most 48 characters, upper case allowed); the lower-case pattern in the first plan was stale. The rubric only words the review checklist: a task's recorded criteria are never added or removed by a pack.
|
|
6280
|
+
|
|
6281
|
+
## Amendment: the console class keeps trust, money, attribution and terminals (#933)
|
|
6282
|
+
|
|
6283
|
+
Plan of record: ai-workspace#60 decision 8. `classifyPeerCommand` (`src/cli/identity.ts`) is policy; every reclassified form changed its daemon-side check in the same change.
|
|
6284
|
+
|
|
6285
|
+
- **The person keeps** `permission`, `agents` beyond `--json`, `role assign`, `budget` changes, every `--as-user` form, a launcher's `personOnlyFlags`, `pet --window`, `watch`, `recovery reattach` and unknown commands, with the unchanged cause line and `OPERATIONAL_PERSON_GUIDANCE`. Also kept, by ruling: `upgrade --yes` (its detached recovery runner inherits the agent markers and the plan covers every running project; deferred to #900 with restart), the memory changes (`promote|demote|forget|supersede|rebuild|machine --label`) and the `goal` changes other than `link` (the pinned amem 0.12.0 records their actor as `user`; the upgrade path is agent-mem 0.13.0 attribution, agent-mem#143, after the pin bump in #958), and `route` beyond `explain` (no such form exists).
|
|
6286
|
+
- **Settings.** The conductor's keys are `CONDUCTOR_SETTING_KEYS` plus `tasks.escalate_after`. `tasks.wip_limits.*`, `tasks.auto_pull` and `tasks.queue_backpressure.*` stay person-only because they decide how much work the hub hands out unattended; the routing-store keys (`routing.classes.*.peers`, `escalate_to`, `route`, `pi_backend`, `routing.stay_switch`) stay person-only because they decide who gets work.
|
|
6287
|
+
- **The conductor** runs `board import`, `intake approve`, `schedule run|pause|resume` and `goal link` through their own daemon request, which calls `admitOperational` (role recheck, one `agent_cli` row) before Tasks sees it; Tasks admits the person or the admitted conductor (`TasksDeps.operational`) and records the conductor as actor. `research backfill`, `bench run` (its tasks proposed as the conductor) and `source add` run in the CLI, so the `admit` handshake is their one audit row, sent only after the CLI has checked their arguments. A native launcher with `cli.authority: "person"` starts through `hub_peer_start`, which needs `conductorStartable`.
|
|
6288
|
+
- **Any agent, on its own task:** `task cancel` through the unchanged `Tasks.cancel` rule of `hub_task_cancel`; `task role` when it owns the task and the task is not in review; `task label` when it owns the task (`task_label_check`), with `by` on the research record, where a person's label for the same record always wins and an agent's counts only without one; `ahub deliverables` filtered by `taskShowAllowed`, the rule `hub_task_show` uses, before any file is read.
|
|
6289
|
+
|
|
6290
|
+
## Operational constants settings mechanism (issue #489 H1)
|
|
6291
|
+
|
|
6292
|
+
H0-classified operational numbers use fixed metadata in
|
|
6293
|
+
`src/hub/operational-settings.ts`, consumed by the existing closed configuration
|
|
6294
|
+
schema, `configApply`, shared settings rows and `DEFAULT_CONFIG`. Registration
|
|
6295
|
+
is static and each schema leaf is explicit; writes, reload authorization,
|
|
6296
|
+
inheritance and undo retain their existing paths. No new configuration file,
|
|
6297
|
+
environment knob or request-level override is added.
|
|
6298
|
+
|
|
6299
|
+
The first conversion is `tasks.groom_fyi_window_min`: 1-1440 minutes, default 10,
|
|
6300
|
+
live at the next planner-notice flush. Its unset behavior is pinned to the
|
|
6301
|
+
independent pre-conversion `GROOM_FYI_WINDOW_MS = 10 * 60_000`. Only state-change
|
|
6302
|
+
FYI coalescing changes; initial/planning/dependency notices, ownership and
|
|
6303
|
+
sessions do not. A new row never grants conductor permission automatically.
|
|
6304
|
+
Safety floors and contract constants are not converted by H1. A later exposed
|
|
6305
|
+
safety floor must allow tightening only in schema bounds and have its own test;
|
|
6306
|
+
wire/storage/protocol contracts remain constants.
|