Compare commits

...

44 Commits

Author SHA1 Message Date
jenkins
8f2abac3dc hold(hermes): keep chat activation topology out of the source release
Restores the five services/hermes manifests to the live state and
removes the evidence policy/RBAC resources, so pushing this source
chain applies nothing to the cluster beyond the inert hux_mode.py key
in the auto-router ConfigMap (old pods are protected by the plugin's
degrade-to-noop import shim). The activation topology returns as a
dedicated commit pinned to the newly built WebUI digest, per the staged
release sequence in docs/hux/HANDOFF.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:46:01 -03:00
jenkins
d50d014572 docs(hux): record completion increments and staged release runbook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:45:38 -03:00
jenkins
ffa003d3fb test(hermes): tolerate fieldRef env entries in chat voice gate
The webui container now carries a POD_NAME fieldRef for HUX subPathExpr
mounts; the voice routing assertions only ever inspected literal
values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:45:15 -03:00
jenkins
e2ad645733 hermes(router-plugin): deliver hux_mode.py through the auto-router ConfigMap
The generator now ships the HUX-06 adoption module alongside the
plugin, and the plugin degrades to its previous behaviour (no mode
adoption) if it starts against a ConfigMap rendered before the module
existed - the plugin can never fail to load mid-transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:44:28 -03:00
jenkins
dc034cb738 hermes(hux): bounded message-text search with privacy enforcement
HUX-03: GET /hux/v1/search now accepts include=message_text, an
explicit opt-in that scans the stored message events of the 100 most
recently active candidate conversations. Forgotten (tombstoned)
conversations, private-mode conversations, restricted events and fully
redacted events never match; the default indexed-fields search and its
response contract are unchanged (the shipped UI keeps requiring
message_text in not_indexed). Paginated mode uses one deterministic
total order (score desc, updated_at desc, id) with an offset cursor.
organization.py at 98% branch coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:43:08 -03:00
jenkins
e40fc5ec5d hermes(chat): stage the HUX-12 evidence producer sidecar
Activation-layer staging, fail-closed until enablement: a per-tenant
hux-evidence-producer sidecar on the exact reviewed WebUI image runs
hux_producer.run_once on a 60s loop, inert until the Vault-staged
evidence key (tolerant init, tmpfs, 0400, staged only for the hux
service and producer containers - never hermes or webui), the policy
ConfigMap, and the scope ConfigMap exist. Adds least-privilege
read-only RBAC (pods+statefulset in hermes, the single named Flux
Kustomization), tenant egress to the Kubernetes API ClusterIP and the
traefik edge, the policy allowlist, hux_producer packaging in the WebUI
image, and a third expected WebUI consumer in the Flux release
renderer. Delivery and image-automation gates enforce the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:39:45 -03:00
jenkins
469e52fd20 hermes(hux): add the HUX-12 release evidence producer
A companion package (outside the network-free hux/ service package)
that independently verifies and binds the whole release chain before
any transition: reviewed proposal URL, Jenkins job/build/result and
revision, immutable Harbor tag/digest equality, Flux kustomization and
applied revision with pin containment, desired workload image, every
Ready pod imageID, bounded-age health receipt, and rollback target.
Pure injectable verifier core, HTTPS-only collectors (SA token for the
Kubernetes API), and an evidence-trust driver that posts exactly one
If-Match transition with deterministic idempotency. Rejects stale,
replayed, downgraded, incomplete, cross-workload, mismatched, and
self-asserted evidence. 100% line and branch coverage (71 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:35:12 -03:00
jenkins
c089a5ec2a hermes(runtime): emit artifacts and web sources from real tool output
HUX-04/HUX-08: after a successful tool execution the hux-runtime plugin
now registers freshly written files as typed artifacts (create or
immutable version by conversation-scoped title, bounded to 1 MiB,
deterministic idempotency keys) and records up to three deduplicated
web sources from real network/web_search output. Emitters are fail-open
and can never break the tool result. Delivered through the plugin
ConfigMap; 95% branch coverage; quality contract updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:35:12 -03:00
jenkins
2eedcd2066 security(hux): private mode denies web, messaging, shell and delegation
HUX-06/HUX-10: a conversation whose stored friendly mode is private now
has network, web_search, send_message, shell and delegate refused by
both the approval resolver and the pre-side-effect gate, regardless of
autonomy policy — matching the mode catalog's tool contract. Memory
writes were already refused by the privacy state. Missing or unbound
conversations keep the existing matrix behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:28:23 -03:00
jenkins
0755e1bbd7 hermes(router-plugin): adopt HUX friendly modes at the route boundary
HUX-06: the auto-router now consults the loopback HUX service (worker
trust, HMAC-derived conversation id from the persisted session) for the
conversation's selected friendly mode and maps it onto the existing
provider-neutral pools: fast->auto/fast, thoughtful/research->auto/deep,
create->auto/balanced; private pins the local route and refuses hosted
overrides. Explicit UI picks keep precedence; every HUX absence or
failure falls through to the previous behaviour unchanged. 95% branch
coverage; quality contract registers the new module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:27:02 -03:00
jenkins
b3d4538b4f docs(hux): describe the integrated staged topology and trust reality
HANDOFF.md and THREAT-MODEL.md now document the actual HMAC subject/
context identity, the shared RWX PVC with kubelet subPathExpr per-pod
isolation and its exact boundary, the uid-10000 no-store-mount agent
model, tmpfs transport key rotation, router X-Hux-* stripping, HUX-12
file-only evidence trust, staged-not-deployed status, and the verified
SO-46/48 (open) and SO-53 (shipped) states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:23:24 -03:00
jenkins
b2cfbc94e8 hermes(webui): track the ignored HUX-04 artifacts card models
The artifacts/ directory matched a repo-wide ignore rule and was left
out of the UI card commit; its suites only passed locally because the
files existed on disk. Force-track the complete card so the source
push carries every module the tests import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:19:03 -03:00
jenkins
13359769dc ci(hermes): make HUX topology gates all-or-nothing adaptive
The delivery and image-automation gates now enforce whichever state the
chat StatefulSet is actually in: with no hux sidecar they require zero
partial HUX wiring (no containers, volumes, PVC, or HUX_* env); with the
sidecar staged they enforce the full strict boundary. This lets the
reviewed source chain merge and build before the activation topology
lands, without ever waiving an activated assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:17:02 -03:00
jenkins
659f70ee03 docs(hermes): record HUX WebUI foundation integration contract
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:42 -03:00
jenkins
71c05cf9c0 hermes(chat): stage HUX sidecar activation topology
Activation (not yet pushed): per-tenant HUX sidecar on the exact live
WebUI image, standalone Astreae RWX PVC with kubelet subPathExpr
per-pod isolation, root init container that provisions 0700 tenant
roots, a persistent HMAC context key, an immutable subject binding and
tmpfs relay/worker keys, the vendored hux-runtime plugin ConfigMap, and
observe-only defaults (HUX_TOOL_ENFORCEMENT=0). Delivery tests pin the
whole boundary; image-automation tests bind the HUX metadata setters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00
jenkins
0b56d04e9d ci(hermes): gate HUX runtime plugin and new families in quality contract
Adds modes, multimodal, releases, release_security, retention_scheduler,
suggestions and the vendored hux-runtime plugin modules to lint, LOC and
coverage enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00
jenkins
9e99470fef release(hermes): render one or two WebUI consumers with HUX binding
The Flux release renderer now accepts an expected-consumer set per
workload (chat may carry the HUX sidecar as a second consumer of the
exact same WebUI image) and binds HUX_IMAGE_TAG/HUX_IMAGE_DIGEST env
metadata to the released tag and digest when those fields are present.
Rendering fails when the binding fields are incomplete, keeping the
image identity single-sourced. Tests adapt to both topologies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00
jenkins
bd63b568e1 security(hermes): strip inbound X-Hux-* at the router boundary
Defense in depth: the tenant router deletes every browser-supplied
X-Hux-* header before asserting its own HUX identity headers, so no
client can forge subject, trust class, or relay key. Regression covers
X-Hux-Subject, X-Hux-Trust and X-Hux-Relay-Key forgeries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00
jenkins
b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00
jenkins
47165274f0 security(hermes): isolate HUX release evidence trust 2026-08-24 03:10:13 -03:00
jenkins
1eda927b71 hermes(webui): integrate trusted HUX workspace 2026-08-24 03:08:56 -03:00
jenkins
31c5eae5ae release(hermes): automate chat router image 2026-08-24 02:46:36 -03:00
jenkins
53d7c2c586 feat(hermes): complete HUX backend families 2026-08-24 02:28:45 -03:00
jenkins
a1070449a7 fix(hermes): vendor HUX runtime hooks 2026-08-24 02:23:04 -03:00
jenkins
438180a99f feat(hermes): gate tool runtime through HUX 2026-08-24 02:17:04 -03:00
jenkins
964103f02b merge(hermes): integrate hardened HUX foundation 2026-08-24 02:01:08 -03:00
jenkins
a260506983 security(hux): converge backend trust boundaries 2026-08-24 01:58:14 -03:00
jenkins
f59f24a427 security(hux): harden backend trust boundaries 2026-08-24 01:31:12 -03:00
jenkins
98c7c6184f docs(hux): point the ledger at the rebased commit ids 2026-08-24 00:54:48 -03:00
jenkins
18b980d6fa hermes(hux): expose conversation privacy state for the agent hook
GET /hux/v1/conversations/{id}/privacy reports forgotten, memory_disabled,
topics, mode and memory_writes_allowed; the worker hook's memory gate reads it
and fails closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:54:34 -03:00
jenkins
e1ef110c8a docs(hux): close the Wave A review in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
a75ca29934 hermes(hux): force restricted sensitivity on secret-bearing artifact content
User artifacts are never rewritten, but a secret pattern in a version marks
the artifact restricted and audits the reason (review a2-C, SO-12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
681b040885 hermes(hux): close Wave A review findings in events, memory, privacy and organization
F3 memory edits go through the same privacy shaping as proposals; F5 seq is
derived from the ledger tail so a crash between append and checkpoint never
duplicates; F7 transitions re-read under the lock and always write with the
loaded revision; F9 secret scrub on titles, passages, claims and notebooks
and forget blanks the conversation document; F13 no ghost conversations
from notices, idempotency under the lock, artifact titles searchable,
normalised paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
6964a9d8c8 hermes(hux): close Wave A review findings in autonomy and the HTTP pipeline
F1 policy writes and allow grants are human-surface only; F2 worker trust is
confined to the hook allowlist and unexpected exceptions become audited 500
error records; F4 external side effects release only for the same run and
argument hash; F6 the gate honours budget exhaustion; F8 only the gateway
can vouch for an empty process registry and failed receipts can be
superseded; F11/F12 receipt revision and unshipped card routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
74c6549965 docs(hux): record the Wave A review outcome in the threat model 2026-08-24 00:48:20 -03:00
jenkins
aeef0f8484 hermes(hux): worker-side hook library for approvals, gates, budgets, stop receipts and events
Stdlib client the agent runtime calls around its tool loop; fails closed for
side effects, fails open for telemetry, never carries raw arguments or outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
1b1a14e972 hermes(hux): contract 1.1.0 additive revision and Wave A consolidation
Adds receipt evidence kind, 422 unprocessable, optional revision on research
records, audit_stale on the privacy policy, per-route body caps (25 MiB
artifact uploads), promotion checks the project exists, memory rules skip
content-free statuses. Handoff ledger covers every Wave A card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
b8fb72fbcc hermes(hux): HUX-01 redacted activity events, HUX-02 memory ledger, HUX-10 privacy behaviour
Per-conversation monotonic event ledger with idempotent emit, SSE replay from
Last-Event-ID, per-kind detail allowlists and secret scrubbing, surface-aware
serve-time redaction; memory as an append-only ledger with no-store,
supersede, forget and retrieval tombstones so 'do not remember' blocks both
persistence and retrieval; sensitive-topic scoping, notices, conversation
forget and a retention job that never touches audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
3ebee2cc15 hermes(hux): HUX-05 autonomy engine and minimal HUX-03 organization API
Scoped policies resolved through the single capability matrix, grants with
server-set expiry, approval queue with once/session/always/deny, pre-side-effect
gate that releases a once approval exactly once against the argument hash,
run budgets with exhaustion events, honest cancellation receipts; projects,
conversations, branch lineage and search over the indexed fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
9f0ffe031c hermes(hux): HUX-04 artifact workspace and HUX-08 research backends
Immutable content-addressed artifact versions with If-Match, lineage that must
resolve under the caller, unified diffs, promotion; sources/passages/citations
with server-side hashing and dedupe, citation integrity checks and revisioned
research notebooks. Cross-tenant access is 404 and audited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
01abf89270 docs(hux): record draft PR #55 in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
b9b7126ee1 docs(hux): start the hermes-next handoff ledger with HUX-11 2026-08-24 00:48:20 -03:00
jenkins
6a5e0d872b hermes(hux): HUX-11 foundation service core with threat and data models
Stdlib per-tenant service: trusted-header identity (router/relay/worker,
constant-time keys, slot pinned to the pod), fail-closed card flags with
capability negotiation, tenant-scoped store (atomic writes, revisions,
append-only ledgers, content-addressed blobs, manifest), audit outcome for
every request, and the /hux/v1 pipeline that maps errors to hux.error.v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
b4145861d2 hermes(hux): freeze hux.v1 contract 1.0.0 and move reference modules into the foundation package
HUX-11 contract freeze: identity/capabilities/manifest/error records, event turn
and idempotency and delegation/side-effect kinds, memory no-store/supersedes/
retrieval removal, revisions for optimistic concurrency, artifact access,
budget scope/spend/subagents and external side-effect gating, notebook notes and
dedupe keys. ADR-0001 records the wire and compatibility rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
279 changed files with 44716 additions and 372 deletions

View File

@ -0,0 +1,339 @@
pipeline {
agent {
kubernetes {
defaultContainer 'python'
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
atlas.bstein.dev/workload: hermes-chat-router-image-builder
spec:
serviceAccountName: hermes-image-builder
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
nodeSelector:
kubernetes.io/arch: arm64
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [titan-20]
imagePullSecrets:
- name: harbor-bstein-robot
containers:
- name: jnlp
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi}
- name: python
image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
command: ["sleep"]
args: ["99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi}
- name: golang
image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
command: ["sleep"]
args: ["99d"]
tty: true
env:
- {name: GOCACHE, value: /tmp/go-cache}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
- name: kaniko
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
command: ["/busybox/sh", "-c"]
args: ["/busybox/sleep 99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
privileged: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 2Gi
limits:
cpu: "1"
memory: 2Gi
ephemeral-storage: 5Gi
"""
}
}
parameters {
booleanParam(
name: 'PUBLISH_IMAGE',
defaultValue: false,
description: 'Publish the reviewed main revision to Harbor.'
)
string(
name: 'EXPECTED_SOURCE_REVISION',
defaultValue: '',
description: 'Full reviewed commit that must be contained by atlas/titan-iac main.'
)
string(
name: 'CONFIRM_PUBLISH',
defaultValue: '',
description: 'Enter PUBLISH HERMES CHAT ROUTER to confirm the release.'
)
}
environment {
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-chat-router'
}
options {
disableConcurrentBuilds()
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
skipDefaultCheckout(true)
timeout(time: 45, unit: 'MINUTES')
}
stages {
stage('Checkout reviewed source') {
steps {
checkout scm
}
}
stage('Enforce release boundary') {
steps {
container('jnlp') {
sh '''
set -eu
mkdir -p build
test "${PUBLISH_IMAGE}" = "true"
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES CHAT ROUTER"
case "${EXPECTED_SOURCE_REVISION}" in
*[!0-9a-f]*|'')
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
exit 2
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-chat-router
case "${BUILD_NUMBER}" in
''|0*|*[!0-9]*)
echo "BUILD_NUMBER must be a positive decimal integer" >&2
exit 2
;;
esac
printf '%s\n' \
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
> build/hermes-chat-router.destination
printf '%s\n' "${actual_revision}" \
> build/hermes-chat-router.source-revision
'''
}
}
}
stage('Validate reviewed router source') {
steps {
container('python') {
sh '''
set -eu
python3 -m pip install --disable-pip-version-check --no-cache-dir \
--target=/tmp/hermes-chat-router-test-deps \
pytest==8.3.4 PyYAML==6.0.2
PYTHONPATH=/tmp/hermes-chat-router-test-deps \
python3 -m pytest -q \
testing/tests/test_hermes_chat_router_release.py \
testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_image_automation.py
'''
}
container('golang') {
sh '''
set -eu
cd services/hermes/router
GO111MODULE=off CGO_ENABLED=0 go test ./...
'''
}
}
}
stage('Reject replay before publish') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py assert-absent \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}"
'''
}
}
}
stage('Build and publish without a daemon') {
steps {
container('kaniko') {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''#!/busybox/sh
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
/busybox/mkdir -p /kaniko/.docker
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
unset HARBOR_USER HARBOR_PASSWORD auth
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
umask 022
/kaniko/executor \
--registry-mirror=harbor-core.harbor.svc.cluster.local \
--insecure-registry=harbor-core.harbor.svc.cluster.local \
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-chat-router" \
--destination="${destination}" \
--digest-file="${WORKSPACE}/build/hermes-chat-router.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-chat-router.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
--label="org.opencontainers.image.title=hermes-chat-router" \
--cleanup \
--push-retry=3
/busybox/chmod 644 \
build/hermes-chat-router.digest build/hermes-chat-router.image
'''
}
}
}
}
stage('Render reviewed Flux handoff') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py render \
--digest-file build/hermes-chat-router.digest \
--image-file build/hermes-chat-router.image \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}" \
--manifest services/hermes/chat-router.yaml \
--output-dir build/hermes-chat-router-release
'''
}
}
}
stage('Verify and archive release evidence') {
steps {
sh '''
set -eu
expected_files="$(printf '%s\n' \
build/hermes-chat-router.destination \
build/hermes-chat-router.digest \
build/hermes-chat-router.image \
build/hermes-chat-router.source-revision \
build/hermes-chat-router-release/hermes-chat-router-deployment.yaml \
build/hermes-chat-router-release/hermes-chat-router-image-update.patch \
build/hermes-chat-router-release/hermes-chat-router-image.json \
| LC_ALL=C sort)"
actual_files="$(find build -type f -print | LC_ALL=C sort)"
test "${actual_files}" = "${expected_files}"
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py verify-evidence \
--digest-file build/hermes-chat-router.digest \
--image-file build/hermes-chat-router.image \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}" \
--manifest services/hermes/chat-router.yaml \
--output-dir build/hermes-chat-router-release
'''
archiveArtifacts(
artifacts: 'build/hermes-chat-router.destination,build/hermes-chat-router.digest,build/hermes-chat-router.image,build/hermes-chat-router.source-revision,build/hermes-chat-router-release/hermes-chat-router-deployment.yaml,build/hermes-chat-router-release/hermes-chat-router-image-update.patch,build/hermes-chat-router-release/hermes-chat-router-image.json',
allowEmptyArchive: false,
fingerprint: true
)
}
}
stage('Publish Flux release tag') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_oci_promote.py \
--destination "${destination}" \
--digest-file build/hermes-chat-router.digest \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}"
'''
}
}
}
}
}

View File

@ -192,8 +192,29 @@ spec:
testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_webui_brand.py \
testing/tests/test_hermes_webui_release.py \
testing/tests/test_hermes_webui_hux_bff.py \
testing/tests/test_hermes_webui_hux_context.py \
testing/tests/test_hermes_webui_hux_integration.py \
testing/tests/test_hermes_webui_hux_backend_e2e.py \
testing/tests/test_hermes_hux_ui_runtime_wave_a.py \
testing/tests/test_hermes_hux_runtime_autonomy_privacy.py \
testing/tests/test_hermes_hux_runtime_stop.py \
testing/tests/test_hermes_hux_ui_runtime_wave_b.py \
testing/tests/test_hermes_hux_runtime_wave_c.py \
testing/tests/test_hermes_hux_runtime_plugin.py \
testing/tests/test_hermes_hux_runtime_vendor_parity.py \
testing/tests/test_hermes_hux_delivery.py \
testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_image_automation.py
HUX_BACKEND_TESTS="$(find testing/tests -maxdepth 1 -type f \
-name 'test_hermes_hux_*.py' \
! -name '*_ui_*' \
! -name '*runtime*' \
! -name '*delivery*' \
| sort)"
test -n "${HUX_BACKEND_TESTS}"
PYTHONPATH=/tmp/hermes-webui-release-test-deps \
python3 -m pytest -q ${HUX_BACKEND_TESTS}
'''
}
}

View File

@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""Verify and render a reviewable Hermes chat-router image release."""
from __future__ import annotations
import argparse
import base64
import difflib
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-chat-router"
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
HARBOR_PROJECT = "bstein"
HARBOR_REPOSITORY = "hermes-chat-router"
IMMUTABLE_REPOSITORY_PATTERN = "hermes-chat-router"
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/hermes-chat-router:"
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
)
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never forward registry credentials to a redirect target."""
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
return None
def _validated(value: str, pattern: re.Pattern[str], label: str) -> str:
"""Return a normalized value only when it matches the release contract."""
normalized = value.strip()
if not pattern.fullmatch(normalized):
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
return normalized
def validate_destination(
destination: str, source_revision: str, build_number: str
) -> tuple[str, str]:
"""Bind the unique build tag to one reviewed source and Jenkins build."""
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
build = _validated(build_number, BUILD_PATTERN, "build number")
match = DESTINATION_PATTERN.fullmatch(destination.strip())
if not match or match.groups() != (revision, build):
raise ValueError("destination does not match the reviewed revision and build")
return revision, build
def validate_kaniko_evidence(
*, digest_text: str, image_text: str, destination: str
) -> str:
"""Cross-check Kaniko's two independent output files."""
digests = digest_text.splitlines()
images = image_text.splitlines()
if len(digests) != 1 or len(images) != 1:
raise ValueError("Kaniko evidence must contain exactly one line per file")
digest = _validated(digests[0], DIGEST_PATTERN, "image digest")
if images[0].strip() != f"{destination}@{digest}":
raise ValueError("Kaniko image evidence does not match destination and digest")
return digest
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
"""Return same-origin registry responses without following redirects."""
opener = urllib.request.build_opener(_NoRedirect())
try:
return opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
return exc
def _authorization(username: str, password: str) -> str:
"""Build a Basic header without placing credentials in a URL."""
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
def _artifact_response(
destination: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> tuple[int, bytes]:
"""Read one exact Harbor artifact by candidate tag."""
if not DESTINATION_PATTERN.fullmatch(destination):
raise ValueError("invalid destination")
tag = urllib.parse.quote(destination.rsplit(":", 1)[1], safe="")
request = urllib.request.Request(
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
f"{HARBOR_REPOSITORY}/artifacts/{tag}?with_immutable_status=true",
headers={
"Accept": "application/json",
"Authorization": _authorization(username, password),
},
method="GET",
)
with opener(request, 20) as response:
body = response.read(1_048_577)
if len(body) > 1_048_576:
raise RuntimeError("Harbor artifact response exceeded the size limit")
return int(response.status), body
def _rules_response(
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> tuple[int, bytes, dict[str, str]]:
"""Read the complete bounded Harbor immutable-rule page."""
request = urllib.request.Request(
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
"?page=1&page_size=100",
headers={
"Accept": "application/json",
"Authorization": _authorization(username, password),
},
method="GET",
)
with opener(request, 20) as response:
body = response.read(1_048_577)
if len(body) > 1_048_576:
raise RuntimeError("Harbor immutable rule response exceeded the size limit")
return int(response.status), body, dict(response.headers)
def _normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
"""Select only immutable-policy fields used by this lane."""
return {
"disabled": bool(rule.get("disabled", False)),
"action": rule.get("action"),
"template": rule.get("template"),
"tag_selectors": [
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
for item in rule.get("tag_selectors") or []
if isinstance(item, dict)
],
"scope_selectors": {
"repository": [
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
for item in (rule.get("scope_selectors") or {}).get("repository", [])
if isinstance(item, dict)
]
},
}
def verify_immutable_policy(
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Fail closed unless one exact active router immutability rule exists."""
status, body, headers = _rules_response(
username=username, password=password, opener=opener
)
if status != 200:
raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
try:
rules = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise RuntimeError("Harbor immutable rule list has an invalid shape")
total = next(
(value for key, value in headers.items() if key.lower() == "x-total-count"),
None,
)
if total is None or not str(total).isdecimal() or int(total) != len(rules):
raise RuntimeError("Harbor immutable rule page is incomplete")
expected = {
"disabled": False,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": IMMUTABLE_TAG_PATTERN,
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": IMMUTABLE_REPOSITORY_PATTERN,
}
]
},
}
matches = [
value
for value in map(_normalized_rule, rules)
if value["tag_selectors"] == expected["tag_selectors"]
and value["scope_selectors"] == expected["scope_selectors"]
]
if matches != [expected]:
raise RuntimeError("Harbor router immutable build-tag policy is not exact")
def assert_tag_absent(
destination: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Reject replay before Kaniko can target an already-used build tag."""
status, _ = _artifact_response(
destination, username=username, password=password, opener=opener
)
if status == 404:
return
if status == 200:
raise RuntimeError("Harbor destination tag already exists")
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
def verify_registry_digest(
destination: str,
digest: str,
source_revision: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Verify Harbor's digest, immutable tag, and persisted source label."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
status, body = _artifact_response(
destination, username=username, password=password, opener=opener
)
if status != 200:
raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
try:
artifact = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Harbor returned invalid artifact JSON") from exc
tag = destination.rsplit(":", 1)[1]
matching = [
item
for item in artifact.get("tags") or []
if isinstance(item, dict) and item.get("name") == tag
]
labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
if artifact.get("digest") != digest:
raise RuntimeError("Harbor digest does not match Kaniko evidence")
if len(matching) != 1 or matching[0].get("immutable") is not True:
raise RuntimeError("Harbor did not enforce the candidate tag as immutable")
if not isinstance(labels, dict) or labels.get(
"org.opencontainers.image.revision"
) != revision:
raise RuntimeError("Harbor OCI source-revision label does not match")
def render_workload(source: str, digest: str) -> str:
"""Replace the single exact router image while preserving the Flux marker."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
identity = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes-chat-router\n"
if not source.startswith("# services/hermes/chat-router.yaml\n" + identity):
raise ValueError("Flux target identity changed")
lines = source.splitlines(keepends=True)
matches: list[int] = []
for index, line in enumerate(lines):
stripped = line.strip()
if not stripped.startswith("image: "):
continue
value = stripped.removeprefix("image: ").split(" #", 1)[0]
image, separator, current_digest = value.rpartition("@")
if separator and re.fullmatch(
rf"{re.escape(DEFAULT_IMAGE)}(?::[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})?",
image,
):
_validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
matches.append(index)
if len(matches) != 1:
raise ValueError(f"expected exactly one router image; found {len(matches)}")
index = matches[0]
indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
comment = ""
if " #" in lines[index]:
comment = " #" + lines[index].split(" #", 1)[1].rstrip("\n")
newline = "\n" if lines[index].endswith("\n") else ""
lines[index] = f"{indent}image: {DEFAULT_IMAGE}@{digest}{comment}{newline}"
return "".join(lines)
def _metadata(
digest: str, revision: str, build: str, destination: str
) -> dict[str, Any]:
return {
"build_number": build,
"digest": digest,
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
"flux_targets": ["apps/Deployment/hermes/hermes-chat-router"],
"image": DEFAULT_IMAGE,
"published_tag": destination,
"source_revision": revision,
}
def _expected_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
) -> dict[str, str]:
source = manifest.read_text(encoding="utf-8")
rendered = render_workload(source, digest)
patch = "".join(
difflib.unified_diff(
source.splitlines(keepends=True),
rendered.splitlines(keepends=True),
fromfile="a/services/hermes/chat-router.yaml",
tofile="b/services/hermes/chat-router.yaml",
)
)
if not patch:
raise ValueError("published digest already matches the Flux target")
metadata = _metadata(digest, source_revision, build_number, destination)
return {
"hermes-chat-router-deployment.yaml": rendered,
"hermes-chat-router-image-update.patch": patch,
"hermes-chat-router-image.json": json.dumps(
metadata, indent=2, sort_keys=True
)
+ "\n",
}
def write_release_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
output_dir: Path,
) -> None:
"""Write deterministic, credential-free Flux handoff evidence."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision, build = validate_destination(destination, source_revision, build_number)
expected = _expected_artifacts(
digest=digest,
source_revision=revision,
build_number=build,
destination=destination,
manifest=manifest,
)
output_dir.mkdir(parents=True, exist_ok=True)
for name, content in expected.items():
(output_dir / name).write_text(content, encoding="utf-8")
def validate_release_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
output_dir: Path,
) -> None:
"""Recompute and compare every archived handoff byte."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision, build = validate_destination(destination, source_revision, build_number)
expected = _expected_artifacts(
digest=digest,
source_revision=revision,
build_number=build,
destination=destination,
manifest=manifest,
)
entries = list(output_dir.iterdir())
if {entry.name for entry in entries} != set(expected) or not all(
entry.is_file() and not entry.is_symlink() for entry in entries
):
raise ValueError("release output must contain exactly three evidence files")
for name, content in expected.items():
if (output_dir / name).read_text(encoding="utf-8") != content:
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
def _credentials() -> tuple[str, str]:
username = os.environ.get("HARBOR_USER", "")
password = os.environ.get("HARBOR_PASSWORD", "")
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
return username, password
def _add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--source-revision", required=True)
parser.add_argument("--build-number", required=True)
parser.add_argument("--destination", required=True)
def main() -> int:
"""Run one fail-closed candidate or evidence operation."""
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
absent = commands.add_parser("assert-absent")
_add_common(absent)
for name in ("render", "verify-evidence"):
command = commands.add_parser(name)
_add_common(command)
command.add_argument("--digest-file", required=True, type=Path)
command.add_argument("--image-file", required=True, type=Path)
command.add_argument("--manifest", required=True, type=Path)
command.add_argument("--output-dir", required=True, type=Path)
args = parser.parse_args()
validate_destination(args.destination, args.source_revision, args.build_number)
if args.command == "verify-evidence":
digest = validate_kaniko_evidence(
digest_text=args.digest_file.read_text(encoding="utf-8"),
image_text=args.image_file.read_text(encoding="utf-8"),
destination=args.destination,
)
validate_release_artifacts(
digest=digest,
source_revision=args.source_revision,
build_number=args.build_number,
destination=args.destination,
manifest=args.manifest,
output_dir=args.output_dir,
)
return 0
username, password = _credentials()
if args.command == "assert-absent":
verify_immutable_policy(username=username, password=password)
assert_tag_absent(
args.destination, username=username, password=password
)
return 0
digest = validate_kaniko_evidence(
digest_text=args.digest_file.read_text(encoding="utf-8"),
image_text=args.image_file.read_text(encoding="utf-8"),
destination=args.destination,
)
verify_registry_digest(
args.destination,
digest,
args.source_revision,
username=username,
password=password,
)
write_release_artifacts(
digest=digest,
source_revision=args.source_revision,
build_number=args.build_number,
destination=args.destination,
manifest=args.manifest,
output_dir=args.output_dir,
)
return 0
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())

View File

@ -18,7 +18,7 @@ from typing import Any, Callable
REGISTRY_ORIGIN = "https://registry.bstein.dev"
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/"
r"(?P<component>hermes-(?:agent|webui|jetson-(?:stt|tts))):"
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
)
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")

View File

@ -49,8 +49,9 @@ def render_workload(
kind: str,
name: str,
image: str = DEFAULT_IMAGE,
expected_images: int | tuple[int, ...] = 1,
) -> str:
"""Replace one WebUI image in one exact Flux workload without reformatting."""
"""Replace every expected WebUI consumer in one exact Flux workload."""
digest = validated(digest, DIGEST_PATTERN, "image digest")
identity = re.compile(
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
@ -75,18 +76,55 @@ def render_workload(
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
matches.append(index)
suffixes[index] = f" #{comment}" if separator else ""
if len(matches) != 1:
allowed = (expected_images,) if isinstance(expected_images, int) else expected_images
if not allowed or any(count < 1 for count in allowed) or len(matches) not in allowed:
raise ValueError(
f"expected exactly one {image!r} image in {kind}/{name}; "
f"expected {allowed} {image!r} image(s) in {kind}/{name}; "
f"found {len(matches)}"
)
index = matches[0]
newline = "\n" if lines[index].endswith("\n") else ""
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
for index in matches:
newline = "\n" if lines[index].endswith("\n") else ""
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
return "".join(lines)
def render_hux_build_metadata(
source: str, digest: str, source_revision: str, build_number: str
) -> str:
"""Bind HUX metadata when the activated sidecar fields are present."""
digest = validated(digest, DIGEST_PATTERN, "image digest")
revision = validated(source_revision, REVISION_PATTERN, "source revision")
build = validated(build_number, BUILD_PATTERN, "build number")
replacements = {
"HUX_IMAGE_TAG": f"git-{revision}-build-{build}-release",
"HUX_IMAGE_DIGEST": digest,
}
rendered = source
present = {
name: rendered.count(f"- {{name: {name}, value:") for name in replacements
}
if set(present.values()) == {0}:
return rendered
if any(count != 1 for count in present.values()):
raise ValueError(f"incomplete HUX build binding fields: {present}")
for name, value in replacements.items():
pattern = re.compile(
rf"^(?P<indent>\s*)- \{{name: {name}, value: '[^'\n]+'\}}(?P<suffix>[^\n]*)$",
re.MULTILINE,
)
rendered, count = pattern.subn(
lambda match: (
f"{match.group('indent')}- {{name: {name}, value: '{value}'}}"
f"{match.group('suffix')}"
),
rendered,
)
if count != 1:
raise ValueError(f"expected exactly one {name} HUX build binding; found {count}")
return rendered
def _targets(chat_manifest: Path, dashboard_manifest: Path):
return (
(
@ -94,12 +132,14 @@ def _targets(chat_manifest: Path, dashboard_manifest: Path):
"StatefulSet",
"hermes-chat-tenant",
"hermes-chat-statefulset.yaml",
(1, 2, 3),
),
(
dashboard_manifest,
"Deployment",
"hermes",
"hermes-dashboard-deployment.yaml",
1,
),
)
@ -122,13 +162,27 @@ def _metadata(
def _rendered_and_patch(
digest: str, chat_manifest: Path, dashboard_manifest: Path
digest: str,
source_revision: str,
build_number: str,
chat_manifest: Path,
dashboard_manifest: Path,
) -> tuple[dict[str, str], str]:
rendered_targets: dict[str, str] = {}
patch_parts: list[str] = []
for path, kind, name, artifact_name in _targets(chat_manifest, dashboard_manifest):
for path, kind, name, artifact_name, expected_images in _targets(chat_manifest, dashboard_manifest):
source = path.read_text(encoding="utf-8")
rendered = render_workload(source, digest, kind=kind, name=name)
rendered = render_workload(
source,
digest,
kind=kind,
name=name,
expected_images=expected_images,
)
if name == "hermes-chat-tenant":
rendered = render_hux_build_metadata(
rendered, digest, source_revision, build_number
)
rendered_targets[artifact_name] = rendered
patch_parts.append(
"".join(
@ -159,7 +213,7 @@ def write_release_artifacts(
destination, source_revision, build_number
)
rendered_targets, patch = _rendered_and_patch(
digest, chat_manifest, dashboard_manifest
digest, source_revision, build_number, chat_manifest, dashboard_manifest
)
if not patch:
raise ValueError("published digest already matches every Flux target")
@ -201,7 +255,7 @@ def validate_release_artifacts(
):
raise ValueError("release output must contain exactly four evidence files")
rendered_targets, patch = _rendered_and_patch(
digest, chat_manifest, dashboard_manifest
digest, source_revision, build_number, chat_manifest, dashboard_manifest
)
metadata = _metadata(digest, source_revision, build_number, destination)
expected = {

View File

@ -18,6 +18,7 @@ from hermes_webui_flux_release import (
DESTINATION_PATTERN,
DIGEST_PATTERN,
REVISION_PATTERN,
render_hux_build_metadata as render_hux_build_metadata,
render_workload as render_workload,
validate_destination,
validate_release_artifacts as validate_flux_release_artifacts,

View File

@ -26,6 +26,10 @@ spec:
kind: Job
name: harbor-hermes-webui-immutability-ensure-1
namespace: harbor
- apiVersion: batch/v1
kind: Job
name: harbor-hermes-chat-router-immutability-ensure-1
namespace: harbor
dependsOn:
- name: core
- name: longhorn

View File

@ -11,6 +11,10 @@ USER root
# Keep WebUI and Hermes pinned together. The WebUI imports Hermes internals,
# while the gateway remains the only process that owns an agent conversation.
COPY --from=webui /apptoo /opt/hermes-webui
COPY dockerfiles/hermes-hux-foundation/hux /opt/hermes-hux/hux
COPY dockerfiles/hermes-hux-foundation/hux_producer /opt/hermes-hux/hux_producer
COPY services/hermes/contracts/hux /opt/hermes-hux/contracts
ENV HUX_CONTRACT_DIR=/opt/hermes-hux/contracts
# Add the Atlas voice bridge as a narrow integration layer. It activates only
# when a tenant's server-side STT capability reports the private Jetson route.
@ -23,6 +27,27 @@ COPY dockerfiles/hermes-webui-atlas-voice-worklet.js /opt/hermes-webui/static/at
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
COPY dockerfiles/hermes-webui-hux-bff-patch.py /tmp/hermes-webui-hux-bff-patch.py
COPY dockerfiles/hermes-webui-hux-context.py /tmp/hermes-webui-hux-context.py
COPY dockerfiles/hermes-webui-hux-context-patch.py /tmp/hermes-webui-hux-context-patch.py
COPY dockerfiles/hermes-webui-hux-patch.py /tmp/hermes-webui-hux-patch.py
COPY dockerfiles/hermes-webui-hux/foundation.css /opt/hermes-webui/static/hux/foundation.css
COPY dockerfiles/hermes-webui-hux/foundation.js /opt/hermes-webui/static/hux/foundation.js
COPY dockerfiles/hermes-webui-hux/shell.js /opt/hermes-webui/static/hux/shell.js
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.css /opt/hermes-webui/static/hux/runtime/autonomy-privacy.css
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.js /opt/hermes-webui/static/hux/runtime/autonomy-privacy.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.css /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.js /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_contract.js /opt/hermes-webui/static/hux/runtime/wave_a_contract.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b.css /opt/hermes-webui/static/hux/runtime/wave_b.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_artifacts_research.js /opt/hermes-webui/static/hux/runtime/wave_b_artifacts_research.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_contract.js /opt/hermes-webui/static/hux/runtime/wave_b_contract.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_projects_modes.js /opt/hermes-webui/static/hux/runtime/wave_b_projects_modes.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_runtime.js /opt/hermes-webui/static/hux/runtime/wave_b_runtime.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.css /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.js /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.js
COPY dockerfiles/hermes-webui-hux/bootstrap.css /opt/hermes-webui/static/hux/bootstrap.css
COPY dockerfiles/hermes-webui-hux/bootstrap.js /opt/hermes-webui/static/hux/bootstrap.js
COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py
COPY dockerfiles/hermes-webui-manifest-patch.py /tmp/hermes-webui-manifest-patch.py
COPY dockerfiles/hermes-webui-release-patch.py /tmp/hermes-webui-release-patch.py
@ -37,12 +62,19 @@ RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-bff-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-context-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-manifest-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-patch.py \
&& HERMES_WEBUI_RELEASE_ID="${HERMES_WEBUI_RELEASE_ID}" \
/opt/hermes/.venv/bin/python /tmp/hermes-webui-release-patch.py
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& test -f /opt/hermes-hux/contracts/flags.json \
&& test -f /opt/hermes-hux/contracts/release-ledger.schema.json \
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -m compileall -q /opt/hermes-hux/hux /opt/hermes-hux/hux_producer \
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -c 'from pathlib import Path; from tempfile import TemporaryDirectory; from hux.server import build_router; root = TemporaryDirectory(); router = build_router(Path(root.name), {"HUX_FLAGS": ""}); assert router.routes; root.cleanup()' \
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
/opt/hermes-webui/api/config.py \
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
@ -63,6 +95,18 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
&& grep -Fq 'from api.hux_bff import proxy_hux' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'from api.hux_context import attach_hux_context' /opt/hermes-webui/api/routes.py \
&& grep -Fq '"schema": "hux.webui_context.v1"' /opt/hermes-webui/api/hux_context.py \
&& grep -Fq '"project_source": raw_project' /opt/hermes-webui/api/hux_context.py \
&& grep -Fq 'static/hux/bootstrap.js' /opt/hermes-webui/static/index.html \
&& grep -Fq "'./static/hux/bootstrap.js' + VQ" /opt/hermes-webui/static/sw.js \
&& grep -Fq 'value.hux_context' /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq "'/hux/v1/capabilities'" /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq "'/hux/v1/context/bootstrap'" /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq 'HermesHuxBootstrap.mergeTrustedContext(S.session, startData)' /opt/hermes-webui/static/messages.js \
&& grep -Fq 'respBody.cancelled===true && respBody.stream_id===streamId' /opt/hermes-webui/static/boot.js \
&& grep -Fq 'prefers-reduced-motion: no-preference' /opt/hermes-webui/static/hux/bootstrap.css \
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
@ -93,6 +137,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
/opt/hermes-webui/api/routes.py \
/opt/hermes-webui/api/upload.py \
/opt/hermes-webui/api/gateway_chat.py \
/opt/hermes-webui/api/hux_bff.py \
/opt/hermes-webui/api/hux_context.py \
/opt/hermes/tools/transcription_tools.py
# Exercise branded responses from the real upstream server process in the
@ -138,6 +184,7 @@ ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
HERMES_WEBUI_SKIP_ONBOARDING=1 \
HERMES_WEBUI_SECURE=1 \
PYTHONPATH=/opt/hermes-hux \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

View File

@ -0,0 +1 @@
"""Hermes User Experience foundation service package."""

View File

@ -0,0 +1,424 @@
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
subtree. Content lives in the content-addressed blob store; the document only
carries ``content_ref`` hashes, so a version can never be rewritten once it
has been appended (SO-30). Blobs are served inside a JSON envelope with
``nosniff`` and never as an executable response type (SO-32). Every
referenced id is resolved under the caller's own subtree (SO-33); a foreign
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
this increment: ``access.mode`` is always ``owner`` (SO-34).
"""
from __future__ import annotations
import base64
import hashlib
from typing import Any
from hux import diffs
from hux.contracts import load_all, validate_record
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54)
def _project_exists(store, project_id: str) -> bool:
"""Ask the organization family when it is present; otherwise only the id shape is known."""
try:
from hux.organization import project_exists
except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts
return True
return project_exists(store, project_id)
FAMILY = "artifacts"
MAX_VERSION_BYTES = 25 * 1024 * 1024
MAX_VERSIONS = 200
MAX_ARTIFACTS = 2000
PAGE_SIZE = 50
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
_SCHEMAS: dict[str, dict[str, Any]] = {}
def _schemas() -> dict[str, dict[str, Any]]:
if not _SCHEMAS:
_SCHEMAS.update(load_all())
return _SCHEMAS
# -- helpers shared with other lanes -------------------------------------------
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
try:
return store.exists(FAMILY, artifact_id)
except Invalid:
return False
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
titles: dict[str, str] = {}
for artifact_id in ids:
if artifact_exists(store, artifact_id):
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
return titles
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
"""Record an activity event through the events lane when it is present.
Imported lazily so module import order never matters; a missing events
module (earlier build) is not an error because the artifact write itself
is the source of truth.
"""
conversation_id = artifact.get("conversation_id")
if conversation_id is None:
return
try:
from hux.events import emit
except ModuleNotFoundError:
return
version = artifact["current_version"]
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
def replay(store: TenantStore, family: str, key: str) -> str | None:
"""Record id previously created under an Idempotency-Key, if any."""
if not key:
return None
for row in store.read(family, "idempotency"):
if row.get("key") == key:
return row["id"]
return None
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
"""Persist an Idempotency-Key to record id mapping."""
if key:
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
# -- record shaping ------------------------------------------------------------
def _actor(identity: Identity) -> dict[str, str]:
if identity.trust == "worker":
return {"type": "system", "id": "worker"}
return {"type": "user", "id": identity.subject}
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
value = body.get(key)
if value is None:
if required:
raise Invalid(f"{key} is required")
return None
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
return value
def secret_hits(data: bytes) -> list[str]:
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
from hux.redaction import scrub_text
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return []
return scrub_text(text)[1]
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
"""Decode the submitted content and return ``(bytes, mime)``."""
text, encoded = body.get("content"), body.get("content_base64")
if isinstance(text, str) and encoded is None:
data = text.encode("utf-8")
elif isinstance(encoded, str) and text is None:
try:
data = base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as error:
raise Invalid("content_base64 is not valid base64") from error
else:
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
if len(data) > MAX_VERSION_BYTES:
raise TooLarge("version exceeds 25 MiB")
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
return data, mime
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
data, mime = _content(body)
digest = hashlib.sha256(data).hexdigest()
claimed = body.get("hash")
if claimed is not None and claimed != f"sha256:{digest}":
raise Invalid("hash does not match the submitted content")
store.put_blob(digest, data)
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
def _optional_id(body: dict[str, Any], key: str) -> str | None:
value = body.get(key)
return None if value is None else check_id(value)
def _load_owned(request: Request) -> dict[str, Any]:
"""The artifact named in the path; unknown, malformed or foreign is 404."""
try:
artifact = request.store.get(FAMILY, request.params["id"])
except Invalid as error:
raise NotFound("artifact not found") from error
if artifact.get("owner") != request.identity.subject:
raise NotFound("artifact not found")
return artifact
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
if not isinstance(number, int) or isinstance(number, bool):
raise Invalid("version must be an integer")
for entry in artifact["versions"]:
if entry["version"] == number:
return entry
raise NotFound(f"version {number} not found")
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
lineage = body.get("lineage")
if lineage is None:
return None
if not isinstance(lineage, dict):
raise Invalid("lineage must be an object")
try:
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
except (Invalid, NotFound) as error:
raise NotFound("lineage artifact not found") from error
if parent.get("owner") != request.identity.subject:
raise NotFound("lineage artifact not found")
version = _version(parent, lineage.get("version"))
return {"artifact_id": parent["id"], "version": version["version"]}
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
"""Append an immutable version entry and persist the document.
The version number must be exactly ``current_version + 1``; any attempt to
write an existing number is a Conflict so history is never rewritten.
"""
if any(v["version"] == entry["version"] for v in artifact["versions"]):
raise Conflict(f"version {entry['version']} already exists")
if entry["version"] != artifact["current_version"] + 1:
raise Conflict("versions are appended in order")
if len(artifact["versions"]) >= MAX_VERSIONS:
raise TooLarge("artifact has reached 200 versions")
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
_check(updated)
return store.put(FAMILY, updated, expected_revision=expected_revision)
def _check(artifact: dict[str, Any]) -> None:
problems = validate_record(artifact, _schemas())
if problems:
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
# -- handlers ------------------------------------------------------------------
def create(request: Request) -> Response:
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
body = _body(request)
key = request.idempotency_key()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key)
if existing is not None:
request.audit("artifacts.create", existing, reason="idempotent_replay")
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
raise TooLarge("tenant has reached 2000 artifacts")
artifact_type = _string(body, "type", 40)
if artifact_type not in ARTIFACT_TYPES:
raise Invalid("unknown artifact type")
sensitivity = body.get("sensitivity", "personal")
if sensitivity not in SENSITIVITIES:
raise Invalid("unknown sensitivity")
hits = secret_hits(_content(body)[0])
if hits:
sensitivity = "restricted"
stamp = now_iso()
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
for field, limit in (("message_id", 120), ("note", 200)):
if _string(body, field, limit, required=False) is not None:
version[field] = body[field]
lineage = _lineage(request, body)
if lineage is not None:
version["lineage"] = lineage
artifact: dict[str, Any] = {
"schema": "hux.artifact.v1",
"id": new_id("art"),
"owner": request.identity.subject,
"type": artifact_type,
"title": _string(body, "title", 200),
"current_version": 1,
"versions": [version],
"sensitivity": sensitivity,
"created_at": stamp,
"updated_at": stamp,
"revision": 1,
"access": {"mode": "owner"},
}
for field in ("conversation_id", "project_id"):
if _optional_id(body, field) is not None:
artifact[field] = body[field]
if _string(body, "language", 40, required=False) is not None:
artifact["language"] = body["language"]
_check(artifact)
stored = request.store.put(FAMILY, artifact)
remember(request.store, FAMILY, key, stored["id"])
request.audit("artifacts.create", stored["id"], reason="secret_pattern_restricted" if hits else "")
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
return Response(201, stored, {"ETag": str(stored["revision"])})
def list_artifacts(request: Request) -> Response:
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
cursor = request.query.get("cursor", "0")
if not cursor.isdigit():
raise Invalid("cursor must be a non-negative integer")
items = [
artifact
for artifact in request.store.scan(FAMILY)
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
]
start = int(cursor)
window = items[start : start + PAGE_SIZE]
request.audit("artifacts.list", "artifacts")
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
def get(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
artifact = _load_owned(request)
request.audit("artifacts.get", artifact["id"])
return Response(200, artifact, {"ETag": str(artifact["revision"])})
def add_version(request: Request) -> Response:
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
body = _body(request)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required to append a version")
key = request.idempotency_key()
with request.store.lock(FAMILY):
artifact = _load_owned(request)
existing = replay(request.store, FAMILY, key)
if existing is not None and existing.split("@")[0] == artifact["id"]:
request.audit("artifacts.version", existing, reason="idempotent_replay")
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
if expected != artifact["revision"]:
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
number = artifact["current_version"] + 1
hits = secret_hits(_content(body)[0])
if hits:
artifact = {**artifact, "sensitivity": "restricted"}
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
if body.get("diff_from") is not None:
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
for field, limit in (("message_id", 120), ("note", 200)):
if _string(body, field, limit, required=False) is not None:
entry[field] = body[field]
lineage = _lineage(request, body)
if lineage is not None:
entry["lineage"] = lineage
stored = append_version(request.store, artifact, entry, expected)
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
request.audit("artifacts.version", f"{stored['id']}@{number}", reason="secret_pattern_restricted" if hits else "")
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
return Response(201, stored, {"ETag": str(stored["revision"])})
def _version_number(request: Request) -> int:
raw = request.params["n"]
if not raw.isdigit() or int(raw) < 1:
raise Invalid("version must be a positive integer")
return int(raw)
def get_version(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
artifact = _load_owned(request)
entry = _version(artifact, _version_number(request))
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
if text is None:
body["content_base64"] = base64.b64encode(data).decode("ascii")
else:
body["content"] = text
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
return Response(200, body, headers)
def diff(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
artifact = _load_owned(request)
to_entry = _version(artifact, _version_number(request))
raw_from = request.query.get("from", "")
if raw_from and not raw_from.isdigit():
raise Invalid("from must be a version number")
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
from_entry = _version(artifact, from_number)
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
def promote(request: Request) -> Response:
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
body = _body(request)
project_id = check_id(body.get("project_id"))
if not _project_exists(request.store, project_id):
raise NotFound("project not found")
expected = request.if_match()
with request.store.lock(FAMILY):
artifact = _load_owned(request)
if expected is not None and expected != artifact["revision"]:
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
version = artifact["current_version"]
if body.get("version") is not None:
version = _version(artifact, body["version"])["version"]
stamp = now_iso()
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
_check(updated)
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
return Response(200, stored, {"ETag": str(stored["revision"])})
def register(router: Router) -> None:
"""Attach HUX-04 routes."""
card = "HUX-04"
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY)
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY)
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)

View File

@ -0,0 +1,40 @@
"""Auditable outcome for every read and mutation.
One JSONL ledger per tenant per day. The record shape is
``common.schema.json#/$defs/audit_outcome``; it never carries request bodies,
only the action name, the resource id and the decision.
"""
from __future__ import annotations
from hux.identity import Identity
from hux.store import TenantStore, now_iso
OUTCOMES = ("allow", "deny", "not_found", "conflict", "flag_off")
def record(store: TenantStore, identity: Identity, action: str, resource: str, outcome: str, reason: str = "") -> dict:
"""Append an audit outcome and return it."""
if outcome not in OUTCOMES:
raise ValueError(f"unknown outcome {outcome!r}")
entry = {
"at": now_iso(),
"identity": identity.record(),
"action": action,
"resource": resource[:200],
"outcome": outcome,
}
if reason:
entry["reason"] = reason[:200]
store.append("audit", now_iso()[:10], entry)
return entry
def recent(store: TenantStore, limit: int = 200) -> list[dict]:
"""Newest audit rows across day ledgers, newest last."""
rows: list[dict] = []
for name in reversed(store.ledgers("audit")):
rows = store.read("audit", name) + rows
if len(rows) >= limit:
break
return rows[-limit:]

View File

@ -0,0 +1,258 @@
"""HUX-05 run controls: budgets, the pre-side-effect gate and cancellation receipts.
The agent hook reports spend per run and asks the gate before every side
effect. The gate releases only against an approved, unexpired approval for the
same run, capability and argument hash; a ``once`` approval is consumed by its
first release (SO-36, SO-37). A stop is not done until a receipt says what
actually happened to the run and its side effects (SO-41).
"""
from __future__ import annotations
import re
from typing import Any
from hux import policy, rules
from hux.errors import Invalid
from hux.http import Request, Response, Router
from hux.identity import Identity
from hux.store import TenantStore, check_id
BUDGETS = "budgets"
RECEIPTS = "receipts"
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
LIMIT_OF = {
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
}
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
def checked_run_id(value: Any) -> str:
"""Return a canonical run id accepted by both body and path APIs."""
if not isinstance(value, str) or not RUN_ID_RE.fullmatch(value):
raise Invalid("run id is malformed or too long")
return value
def run_id_from(request: Request) -> str:
"""The run id in the path; the router already bounded its alphabet."""
return checked_run_id(request.params["id"])
# -- budgets -------------------------------------------------------------------
def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
"""Limit names whose spend has reached them; a zero limit is exhausted immediately."""
return [LIMIT_OF[k] for k in SPEND_KEYS if LIMIT_OF[k] in limits and spent.get(k, 0) >= limits[LIMIT_OF[k]]]
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
"""Current state for a run, enforced against its conversation's policy epoch aggregate."""
run_id = checked_run_id(run_id)
doc_id = f"bud_{policy.run_key(run_id)}"
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id)
if known_conversation and conversation_id and known_conversation != conversation_id:
raise Invalid("run is already bound to another conversation")
conversation_id = known_conversation or conversation_id
level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None)
effective = policy.effective_policy(store, identity, level, scope_id)
limits = {k: v for k, v in effective["budgets"].items() if k != "scope"}
epoch = str(effective.get("_budget_epoch") or f"{effective['id']}:{effective['revision']}")
same_epoch = stored.get("_budget_epoch") == epoch
run_spent = {k: int(stored["spent"].get(k, 0)) if same_epoch else 0 for k in SPEND_KEYS}
aggregate = {k: 0 for k in SPEND_KEYS}
for record in store.scan(BUDGETS):
if record.get("_conversation_id") != conversation_id or record.get("_budget_epoch") != epoch:
continue
for key in SPEND_KEYS:
aggregate[key] += int(record.get("spent", {}).get(key, 0))
return policy.checked({
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": aggregate, "limits": limits,
"exhausted": exhausted(aggregate, limits), "_conversation_id": conversation_id, "_id": doc_id,
"_run_spent": run_spent, "_budget_epoch": epoch,
})
def get_budget(request: Request) -> Response:
"""``GET /hux/v1/runs/{id}/budget``: spend so far and what is exhausted."""
state = budget_state(request.store, request.identity, run_id_from(request))
request.audit("budgets.read", state["_id"])
return Response(200, policy.public(state))
def post_budget(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
policy.require_worker(request.identity, "budget reports")
body = policy.body_dict(request)
run_id = run_id_from(request)
conversation_id = body.get("conversation_id")
if conversation_id is not None:
conversation_id = check_id(conversation_id)
increments = {}
for key in SPEND_KEYS:
value = body.get(key, 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise Invalid(f"{key} must be a non-negative integer")
increments[key] = value
with request.store.lock(BUDGETS):
before = budget_state(request.store, request.identity, run_id, conversation_id)
known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else []
spent = {k: before["_run_spent"][k] + increments[k] for k in SPEND_KEYS}
doc = {
"id": before["_id"], "run_id": run_id, "spent": spent,
"_conversation_id": before["_conversation_id"], "_budget_epoch": before["_budget_epoch"],
}
request.store.put(BUDGETS, doc)
after = budget_state(request.store, request.identity, run_id, conversation_id)
request.audit("budgets.write", after["_id"])
newly = [k for k in after["exhausted"] if k not in known]
if newly:
policy.emit(request.store, request.identity, after["_conversation_id"], "budget.exhausted",
f"Budget exhausted: {', '.join(newly)}", run_id=run_id)
return Response(200, policy.public(after))
# -- gate ----------------------------------------------------------------------
def hashes_of(approval: dict[str, Any]) -> set[str]:
"""Argument hashes the approval was requested for (tool_call evidence with a hash)."""
return {e["hash"] for e in approval["request"].get("evidence", []) if e.get("kind") == "tool_call" and e.get("hash")}
def run_conversation(store: TenantStore, run_id: str) -> str | None:
"""The conversation a trusted Worker bound to this run in its budget document (F4)."""
doc_id = f"bud_{policy.run_key(run_id)}"
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
return store.get(BUDGETS, doc_id)["_conversation_id"]
return None
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
"""The approval that releases this side effect, or why none does, plus the conversation the gate settled on.
Every release uses an approval record created for this exact run. A
session/always grant may let a later run create a new approved record,
but the old record itself never crosses run ids. External effects also
require the same argument hash; ``once`` names exactly one hash (SO-36,
SO-37, SO-39).
"""
reason = "no approval for this run and capability"
for record in store.scan(policy.APPROVALS):
record = policy.refresh(store, record)
if record["capability"] != capability:
continue
same_run = record["run_id"] == run_id
choice = record.get("decision", {}).get("choice")
if not same_run:
continue
if record["status"] != "approved":
reason = f"approval {record['id']} is {record['status']}"
continue
if policy.parse(record["expires_at"]) <= policy.now():
reason = f"approval {record['id']} has expired"
continue
if external and not record["request"]["external"]:
reason = f"approval {record['id']} was not requested as external"
continue
if (external or choice == "once") and argument_hash not in hashes_of(record):
reason = f"approval {record['id']} was for different arguments"
continue
if choice == "once" and record.get("_consumed_at"):
reason = f"approval {record['id']} was already consumed"
continue
return record, "released", conversation_id or record["conversation_id"]
return None, reason, conversation_id
def gate(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
policy.require_worker(request.identity, "gate checks")
body = policy.body_dict(request)
run_id = run_id_from(request)
capability = body.get("capability")
argument_hash = body.get("argument_hash")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
raise Invalid("argument_hash must be sha256:<hex>")
external = bool(body.get("external", False))
conversation_id = run_conversation(request.store, run_id)
if policy.private_mode_denies(request.store, conversation_id, capability):
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="private_mode")
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: private mode", run_id=run_id)
return Response(200, {"proceed": False, "reason": "private_mode"})
state = budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
policy.emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=run_id)
return Response(200, {"proceed": False, "reason": "budget_exhausted", "exhausted": state["exhausted"]})
with request.store.lock(policy.APPROVALS):
record, reason, conversation_id = matching_approval(request.store, run_id, capability, argument_hash, external, conversation_id)
if record is not None and record["decision"]["choice"] == "once":
request.store.put(policy.APPROVALS, {**record, "_consumed_at": policy.iso(policy.now())})
if record is None:
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="policy_violation")
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: {reason}"[:280], run_id=run_id)
return Response(200, {"proceed": False, "reason": reason})
request.audit("gate.check", record["id"])
policy.emit(request.store, request.identity, conversation_id, "side_effect.released", f"{capability} released by approval {record['id']}",
run_id=run_id, evidence=[{"kind": "approval", "id": record["id"]}])
return Response(200, {"proceed": True, "approval_id": record["id"], "reason": reason})
# -- stop ----------------------------------------------------------------------
def stop_outcome(body: dict[str, Any]) -> tuple[str, str]:
"""Return the cancellation outcome proven by the gateway's process registry (F8, SO-41)."""
if body.get("already_complete"):
return "already_complete", "already_complete"
if body.get("process_registry_empty") is not True:
return "failed_to_cancel", "process_registry_not_empty"
return "cancelled", "cancelled"
def stop(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it.
A ``failed_to_cancel`` receipt is the one non-terminal outcome: a later
stop that really cancels or finds the run complete supersedes it with a
revision bump; an identical repeat still replays (F8).
"""
policy.require_worker(request.identity, "stop receipts")
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
outcome, reason = stop_outcome(body)
with request.store.lock(RECEIPTS):
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
request.audit("runs.stop", receipt_id, reason="replayed")
return Response(200, policy.public(existing))
stamp = policy.iso(policy.now())
record: dict[str, Any] = {
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
"requested_at": existing["requested_at"] if existing else stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
}
if outcome != "failed_to_cancel":
record["completed_at"] = stamp
conversation_id = body.get("conversation_id", existing.get("conversation_id") if existing else None)
if conversation_id is not None:
record["conversation_id"] = check_id(conversation_id)
stored = request.store.put(RECEIPTS, policy.checked(record), expected_revision=existing["revision"] if existing else None)
request.audit("runs.stop", receipt_id, reason=reason if existing is None else f"superseded:{reason}")
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome} ({reason})",
run_id=run_id, evidence=[{"kind": "run", "id": receipt_id}])
return Response(201, policy.public(stored))
def register_routes(router: Router) -> None:
"""Attach the run-scoped HUX-05 routes; called by ``hux.policy.register``."""
router.add("GET", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.read", get_budget)
router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget)
router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate)
router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop)

View File

@ -10,23 +10,30 @@ loudly rather than silently passing.
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux"
CONTRACT_DIR = Path(
os.environ.get("HUX_CONTRACT_DIR")
or Path(__file__).resolve().parents[3] / "services" / "hermes" / "contracts" / "hux"
)
SCHEMA_FILES = (
"common.schema.json",
"identity.schema.json",
"event.schema.json",
"memory.schema.json",
"project.schema.json",
"artifact.schema.json",
"permission.schema.json",
"mode.schema.json",
"multimodal.schema.json",
"citation.schema.json",
"suggestion.schema.json",
"privacy.schema.json",
"release.schema.json",
"release-ledger.schema.json",
)
SUPPORTED_KEYWORDS = frozenset(
{
@ -70,7 +77,7 @@ def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]:
def _walk(node: Any, path: str, problems: list[str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
if path.endswith("/properties") or path.endswith("/$defs"):
if path.endswith(("/properties", "/$defs")):
_walk(value, f"{path}/{key}", problems)
continue
if key not in SUPPORTED_KEYWORDS:

View File

@ -0,0 +1,50 @@
"""Version-to-version comparison for artifacts (HUX-04).
Text artifacts get a unified diff from ``difflib``; anything that is not
valid UTF-8, or whose artifact type is binary, is described by sizes and
hashes only so the UI never tries to render bytes as text.
"""
from __future__ import annotations
import difflib
import hashlib
from typing import Any
TEXT_TYPES = frozenset({"markdown", "code", "html", "svg", "json", "csv"})
def is_text_type(artifact_type: str) -> bool:
"""True for artifact types whose content is served as UTF-8 text."""
return artifact_type in TEXT_TYPES
def decode_text(data: bytes) -> str | None:
"""UTF-8 decode, or None when the bytes are not text."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return None
def unified(artifact_type: str, older: bytes, newer: bytes, from_n: int, to_n: int) -> dict[str, Any]:
"""Diff record ``{"from", "to", "unified"|"binary"}`` for two versions."""
record: dict[str, Any] = {"from": from_n, "to": to_n}
old_text = decode_text(older) if is_text_type(artifact_type) else None
new_text = decode_text(newer) if is_text_type(artifact_type) else None
if old_text is None or new_text is None:
record["binary"] = {
"from_bytes": len(older),
"to_bytes": len(newer),
"from_hash": "sha256:" + hashlib.sha256(older).hexdigest(),
"to_hash": "sha256:" + hashlib.sha256(newer).hexdigest(),
}
return record
lines = difflib.unified_diff(
old_text.splitlines(keepends=True),
new_text.splitlines(keepends=True),
fromfile=f"v{from_n}",
tofile=f"v{to_n}",
)
record["unified"] = "".join(lines)
return record

View File

@ -0,0 +1,92 @@
"""Error types that map one-to-one onto ``hux.error.v1`` records."""
from __future__ import annotations
class HuxError(Exception):
"""Base class; ``status`` and ``code`` follow identity.schema.json#/$defs/error."""
status = 500
code = "invalid"
def __init__(self, message: str, details: list[str] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = list(details or [])
def record(self) -> dict:
"""Serialise as a ``hux.error.v1`` record."""
body = {"schema": "hux.error.v1", "status": self.status, "code": self.code, "message": self.message[:280]}
if self.details:
body["details"] = [d[:280] for d in self.details[:32]]
return body
class Unauthorized(HuxError):
"""Identity headers missing, malformed or not trusted."""
status, code = 401, "unauthorized"
class Forbidden(HuxError):
"""Identity is valid but does not own the resource."""
status, code = 403, "forbidden"
class NotFound(HuxError):
"""Resource does not exist for this tenant."""
status, code = 404, "not_found"
class FlagOff(HuxError):
"""Card (or one of its dependencies) is disabled; indistinguishable from not found on purpose."""
status, code = 404, "flag_off"
class Conflict(HuxError):
"""If-Match revision mismatch or duplicate create."""
status, code = 409, "conflict"
class Invalid(HuxError):
"""Body failed contract validation."""
status, code = 400, "invalid"
class Unprocessable(HuxError):
"""Well-formed body that violates a rule (hash mismatch, policy violation)."""
status, code = 422, "unprocessable"
class TooLarge(HuxError):
"""Body, record or family exceeds its bound."""
status, code = 413, "too_large"
class RateLimited(HuxError):
"""The caller exceeded the bounded per-subject request rate."""
status, code = 429, "rate_limited"
def __init__(self, retry_after: int) -> None:
super().__init__("request rate limit exceeded")
self.retry_after = max(1, int(retry_after))
class ApprovalRequired(HuxError):
"""An action needs an approval record before it may proceed."""
status, code = 403, "approval_required"
class BudgetExhausted(HuxError):
"""A run budget has been spent."""
status, code = 429, "budget_exhausted"

View File

@ -0,0 +1,315 @@
"""HUX-01 activity events: the per-conversation append-only log and its readers.
``emit`` is the one write path every family uses. It runs the redaction
pipeline, allocates ``seq`` under the conversation lock, honours idempotency
keys and keeps a checkpoint document per conversation. Readers page by
``after_seq`` or follow an SSE stream whose ``id:`` is the seq, and every
served record passes serve-time redaction for the caller's surface.
"""
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
from typing import Any
from collections.abc import Iterator
from hux import contracts, redaction
from hux.errors import Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, new_id, now_iso
FAMILY = "events"
SEQ_FAMILY = "events_seq"
IDEM_FAMILY = "events_idem"
PAGE_MAX = 200
PAGE_DEFAULT = 100
STREAM_MAX_POLLS = 900
STREAM_POLL_SECONDS = 1.0
SCHEMAS = contracts.load_all()
KINDS = frozenset(contracts.load_schema("event.schema.json")["properties"]["kind"]["enum"])
USER_KINDS = frozenset({"message.user", "approval.resolved", "run.cancelled", "memory.forgotten", "suggestion.dismissed"})
_streams: dict[str, int] = {}
_streams_guard = threading.Lock()
def _seq_id(conversation_id: str) -> str:
return f"seq_{conversation_id}"
def is_private(store: TenantStore, conversation_id: str) -> bool:
"""True when the conversation document (HUX-03) says the mode is private (SO-28)."""
try:
return store.get("conversations", conversation_id).get("mode") == "private"
except NotFound:
return False
def _ledger_path(store: TenantStore, conversation_id: str) -> Path:
return store.root / FAMILY / f"{conversation_id}.jsonl"
def conversation_known(store: TenantStore, conversation_id: str) -> bool:
"""Ownership check (SO-18): the conversation document, its seq checkpoint or its event ledger exists in this subject's tree."""
return (
store.exists("conversations", conversation_id)
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
or _ledger_path(store, conversation_id).is_file()
)
def last_seq(store: TenantStore, conversation_id: str) -> int:
"""The seq of the newest complete ledger line, read from the file tail (0 when the ledger is empty or absent).
A crash between the ledger append and the checkpoint write leaves the
checkpoint behind the ledger; ``emit`` takes the larger of the two so a seq
is never handed out twice (F5). Only the last ``LINE_CAP_BYTES`` window is
read, and a torn trailing line is skipped.
"""
path = _ledger_path(store, conversation_id)
if not path.is_file():
return 0
with open(path, "rb") as handle:
handle.seek(0, 2)
size = handle.tell()
handle.seek(max(0, size - redaction.LINE_CAP_BYTES - 2))
tail = handle.read()
for line in reversed(tail.split(b"\n")):
if not line:
continue
try:
return int(json.loads(line).get("seq", 0))
except (json.JSONDecodeError, AttributeError, ValueError):
continue
return 0
def _actor(identity: Identity, kind: str) -> dict[str, str]:
if kind in USER_KINDS and identity.trust != "worker":
return {"type": "user", "id": identity.subject}
if identity.trust == "worker":
return {"type": "system", "id": "hux-worker"}
return {"type": "assistant", "id": "hermes"}
def _find_replay(store: TenantStore, conversation_id: str, key: str) -> dict[str, Any] | None:
for row in store.read(IDEM_FAMILY, conversation_id):
if row.get("idempotency_key") == key:
for event in store.read(FAMILY, conversation_id):
if event.get("id") == row.get("event_id"):
return event
return None
def build(identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None, evidence: list | None,
sensitivity: str, run_id: str | None, turn: int | None, correlation_id: str | None, idempotency_key: str | None) -> dict[str, Any]:
"""Run the redaction pipeline and shape an unsequenced ``hux.event.v1`` record."""
if kind not in KINDS:
raise Invalid(f"unknown event kind {kind!r}")
if sensitivity not in ("public", "personal", "sensitive", "restricted"):
raise Invalid("unknown sensitivity")
hits: list[str] = []
summary_clean = redaction.scrub_value(str(summary or "").strip(), hits)[: redaction.SUMMARY_MAX] or "[empty]"
detail_clean = redaction.scrub_value(redaction.filter_detail(kind, detail), hits)
detail_clean, truncated = redaction.cap_detail(detail_clean)
evidence_clean = redaction.scrub_value(redaction.filter_evidence(evidence), hits)
stamp = now_iso()
record: dict[str, Any] = {
"schema": "hux.event.v1",
"id": new_id("evt"),
"seq": 0,
"ts": stamp,
"conversation_id": conversation_id,
"kind": kind,
"summary": summary_clean,
"provenance": {"surface": identity.surface, "actor": _actor(identity, kind), "recorded_at": stamp, "conversation_id": conversation_id},
"sensitivity": sensitivity,
"redaction": redaction.derive_level(sensitivity, hits, truncated),
"turn": max(0, int(turn or 0)),
"identity": identity.record(),
}
if detail_clean:
record["detail"] = detail_clean
if evidence_clean:
record["evidence"] = evidence_clean
if run_id:
record["run_id"] = str(run_id)[:120]
record["provenance"]["run_id"] = record["run_id"]
if correlation_id:
record["correlation_id"] = str(correlation_id)[:120]
if idempotency_key:
record["idempotency_key"] = idempotency_key
return record
def emit_with_status(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
correlation_id: str | None = None, idempotency_key: str | None = None) -> tuple[dict[str, Any] | None, bool]:
"""Like ``emit`` but also says whether the record was an idempotent replay. None means private mode (nothing written)."""
if is_private(store, conversation_id):
return None, False
record = build(identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
with store.lock(f"{FAMILY}:{conversation_id}"):
if idempotency_key:
existing = _find_replay(store, conversation_id, idempotency_key)
if existing is not None:
return existing, True
seq_id = _seq_id(conversation_id)
checkpoint = store.get(SEQ_FAMILY, seq_id) if store.exists(SEQ_FAMILY, seq_id) else {"id": seq_id, "next_seq": 1, "last_event_id": ""}
record["seq"] = max(int(checkpoint["next_seq"]), last_seq(store, conversation_id) + 1)
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("event failed contract validation", problems)
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
if len(line) > redaction.LINE_CAP_BYTES:
raise TooLarge("event line exceeds 64 KiB")
store.append(FAMILY, conversation_id, record)
store.put(SEQ_FAMILY, {**checkpoint, "next_seq": record["seq"] + 1, "last_event_id": record["id"], "checkpointed_at": now_iso()})
if idempotency_key:
store.append(IDEM_FAMILY, conversation_id, {"idempotency_key": idempotency_key, "event_id": record["id"], "seq": record["seq"], "at": record["ts"]})
return record, False
def emit(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
correlation_id: str | None = None, idempotency_key: str | None = None) -> dict[str, Any] | None:
"""Append one event and return the stored record (None when the conversation is private)."""
record, _ = emit_with_status(store, identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
return record
def read_after(store: TenantStore, conversation_id: str, after_seq: int, limit: int) -> list[dict[str, Any]]:
"""Stored events with ``seq > after_seq``, oldest first, at most ``limit``."""
rows = [row for row in store.read(FAMILY, conversation_id) if int(row.get("seq", 0)) > after_seq]
return rows[:limit]
def rewrite_full(store: TenantStore, conversation_id: str, summary: str, reason: str, match=None) -> int:
"""Rewrite matching events of one conversation to ``redaction.level: full`` (SO-24, forget, decay). Returns the count."""
with store.lock(f"{FAMILY}:{conversation_id}"):
rows = store.read(FAMILY, conversation_id)
changed = 0
out: list[dict[str, Any]] = []
for row in rows:
if row.get("redaction", {}).get("level") != "full" and (match is None or match(row)):
row = redaction.full_redaction(row, summary, reason)
changed += 1
out.append(row)
if changed:
store.rewrite(FAMILY, conversation_id, out)
return changed
def redact_memory_references(store: TenantStore, memory_id: str) -> int:
"""Fully redact every event, in any conversation, that names a forgotten memory id (SO-24)."""
def references(row: dict[str, Any]) -> bool:
if row.get("detail", {}).get("memory_id") == memory_id:
return True
return any(ref.get("kind") == "memory" and ref.get("id") == memory_id for ref in row.get("evidence", []))
return sum(rewrite_full(store, name, "[forgotten memory]", "memory forgotten", references) for name in store.ledgers(FAMILY))
# -- routes ------------------------------------------------------------------
def _int_query(request: Request, name: str, default: int, floor: int = 0) -> int:
raw = request.query.get(name, "")
if raw == "":
return default
if not raw.lstrip("-").isdigit():
raise Invalid(f"{name} must be an integer")
return max(floor, int(raw))
def _require_conversation(request: Request) -> str:
conversation_id = request.params["id"]
if not conversation_known(request.store, conversation_id):
raise NotFound("conversation not found")
return conversation_id
def list_events(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/events?after_seq=&limit=``: one page, ``next`` is the last seq served."""
conversation_id = _require_conversation(request)
after_seq = _int_query(request, "after_seq", 0)
limit = min(_int_query(request, "limit", PAGE_DEFAULT, 1), PAGE_MAX)
rows = read_after(request.store, conversation_id, after_seq, limit)
items = [redaction.redact_record(row, request.identity.surface) for row in rows]
request.audit("events.list", conversation_id)
return page(items, items[-1]["seq"] if len(items) == limit else None)
def append_event(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/events``: append from the trusted hop; ids and seq are server-assigned (SO-15)."""
conversation_id = _require_conversation(request)
body = request.body if isinstance(request.body, dict) else None
if body is None:
raise Invalid("body must be an object")
if "id" in body or "seq" in body:
raise Invalid("id and seq are server-assigned")
if not isinstance(body.get("kind"), str) or not isinstance(body.get("summary"), str):
raise Invalid("kind and summary are required")
detail = body.get("detail") if isinstance(body.get("detail"), dict) else None
evidence = body.get("evidence") if isinstance(body.get("evidence"), list) else None
turn = body.get("turn") if isinstance(body.get("turn"), int) else None
record, replayed = emit_with_status(
request.store, request.identity, conversation_id, body["kind"], body["summary"], detail, evidence,
body.get("sensitivity", "personal"), body.get("run_id"), turn, body.get("correlation_id"), request.idempotency_key() or None,
)
if record is None:
request.audit("events.append", conversation_id, "allow", "private_mode")
return Response(204)
request.audit("events.append", f"{conversation_id}/{record['id']}", "allow", "replayed" if replayed else "")
served = redaction.redact_record(record, request.identity.surface)
return Response(200 if replayed else 201, served, {"HUX-Replayed": "true"} if replayed else {})
def _sse(record: dict[str, Any]) -> bytes:
return f"id: {record['seq']}\nevent: {record['kind']}\ndata: {json.dumps(record, sort_keys=True)}\n\n".encode()
def stream_events(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/events/stream``: SSE replay from ``Last-Event-ID`` (or ``after_seq``), then a bounded live poll.
``max_polls`` and ``poll_ms`` (query) bound the live phase so a client, or a
test, decides how long to wait; the server caps them at the 15 minute idle
limit (SO-17). A second stream for the same (subject, conversation) closes
the first.
"""
conversation_id = _require_conversation(request)
last_id = request.header("Last-Event-ID")
cursor = int(last_id) if last_id.isdigit() else _int_query(request, "after_seq", 0)
max_polls = min(_int_query(request, "max_polls", STREAM_MAX_POLLS), STREAM_MAX_POLLS)
poll_seconds = min(_int_query(request, "poll_ms", int(STREAM_POLL_SECONDS * 1000)), 60000) / 1000
store, surface = request.store, request.identity.surface
key = f"{request.identity.subject}:{conversation_id}"
with _streams_guard:
token = _streams[key] = _streams.get(key, 0) + 1
request.audit("events.stream", conversation_id)
def generate() -> Iterator[bytes]:
position = cursor
yield b"retry: 2000\n\n"
polls = 0
while True:
for record in read_after(store, conversation_id, position, PAGE_MAX):
position = record["seq"]
yield _sse(redaction.redact_record(record, surface))
if polls >= max_polls or _streams.get(key) != token:
break
polls += 1
yield b": keepalive\n\n"
time.sleep(poll_seconds)
return Response(200, stream=generate)
def register(router: Router) -> None:
"""Attach HUX-01 routes."""
router.add("GET", "/hux/v1/conversations/{id}/events", "HUX-01", "events.list", list_events)
router.add("POST", "/hux/v1/conversations/{id}/events", "HUX-01", "events.append", append_event)
router.add("GET", "/hux/v1/conversations/{id}/events/stream", "HUX-01", "events.stream", stream_events)

View File

@ -0,0 +1,150 @@
"""Per-card feature flags and the capabilities record clients negotiate with.
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
when it and every card it depends on are enabled, so a half-configured
deployment fails closed. Route ownership per card is declared here so the
capabilities record can tell a client exactly what it may call, and the
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
reach at all.
"""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from hux.errors import FlagOff
from hux.identity import Identity
from hux.rules import flag_enabled, flag_registry
CONTRACT_VERSION = "1.1.0"
RELEASE_TAG = re.compile(r"^git-([0-9a-f]{40})-build-[1-9][0-9]*-release$")
CARD_ROUTES: dict[str, list[str]] = {
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest", "/hux/v1/context/bootstrap"],
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
"HUX-06": ["/hux/v1/modes", "/hux/v1/projects/{project_id}/conversations/{id}/mode"],
"HUX-07": [
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items",
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}",
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections",
"/hux/v1/projects/{project_id}/conversations/{id}/capture-intents",
],
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
"HUX-09": [
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate",
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions",
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states",
],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"],
"HUX-12": [
"/hux/v1/projects/{project_id}/conversations/{id}/releases",
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}",
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions",
],
}
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
# Everything else is 403 before the flag check. These are the agent-hook
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
# the privacy policy, memory retrieval and proposals, research inputs and
# artifact writes. The conversation read is there so the hook can honour
# private mode (SO-28) before proposing a memory.
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"),
("POST", "/hux/v1/context/bootstrap"),
("POST", "/hux/v1/approvals"),
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
("GET", "/hux/v1/runs/{id}/budget"),
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/conversations/{id}/privacy"),
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
("GET", "/hux/v1/modes"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"),
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
})
def worker_may_call(method: str, template: str) -> bool:
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
return (method, template) in WORKER_ROUTES
class Flags:
"""Snapshot of which cards are on for this process."""
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
self._environ = dict(os.environ if environ is None else environ)
self._registry = flag_registry()
self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes)
def bind_routes(self, routes: Mapping[str, set[str]]) -> None:
"""Bind capability flags to the route templates actually registered by this process."""
unknown = set(routes) - set(self._registry)
if unknown:
raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}")
for card, actual in routes.items():
undeclared = actual - set(CARD_ROUTES.get(card, []))
if undeclared:
raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}")
self._route_cards = frozenset(
card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set())
)
def enabled(self, card: str) -> bool:
"""True only for a route-backed card whose configured flag chain is on."""
entry = self._registry.get(card)
enabled = bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ)
if enabled and card == "HUX-12":
from hux.release_security import configured
return configured(self._environ)
return enabled
def require(self, card: str) -> None:
"""Raise FlagOff unless the card is enabled."""
if not self.enabled(card):
raise FlagOff(f"{card} is not enabled")
@property
def environ(self) -> Mapping[str, str]:
"""Read-only process configuration for route-family policy checks."""
return self._environ
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
"""Serialise ``hux.capabilities.v1`` for one caller."""
cards = [
{"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])}
for card, entry in sorted(self._registry.items())
]
server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v}
return {
"schema": "hux.capabilities.v1",
"contract_version": CONTRACT_VERSION,
"identity": identity.record(),
"cards": cards,
"server": server,
}
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
"""Commit and image digest the pod was started with, when the operator set them."""
environ = os.environ if environ is None else environ
tag = environ.get("HUX_IMAGE_TAG", "")
commit = environ.get("HUX_BUILD_COMMIT", "")
if tag:
match = RELEASE_TAG.fullmatch(tag)
if not match:
raise ValueError("HUX_IMAGE_TAG is not an immutable WebUI release tag")
if commit and commit != match.group(1):
raise ValueError("HUX build commit conflicts with the immutable image tag")
commit = match.group(1)
return {"commit": commit, "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}

View File

@ -0,0 +1,185 @@
"""HUX-11 routes: capabilities, manifest, and trusted context bootstrap."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import stat
from pathlib import Path
from typing import Any
from hux import contracts, organization
from hux.errors import Conflict, Forbidden, Invalid
from hux.flags import CONTRACT_VERSION
from hux.http import Request, Response, Router
from hux.store import check_id, now_iso
CONTEXT_FAMILY = "context_bindings"
CONTEXT_SCHEMA = "hux.context_bootstrap.v1"
CONTEXT_KEY_BYTES = 32
CONTEXT_MESSAGE = b"hux.context.id.v1"
RAW_RE = re.compile(r"^[A-Za-z0-9._:/@+\-]{1,240}$")
CONTEXT_ID_RE = re.compile(r"^(ses|conv|prj)_[0-9a-f]{32}$")
SCHEMAS = contracts.load_all()
def _context_key(environ: dict[str, str]) -> bytes:
"""Read the exact 0600 regular key file; inline keys and links fail closed."""
raw_path = environ.get("HUX_CONTEXT_KEY_FILE", "")
if not raw_path:
raise Forbidden("context identity key is unavailable")
path = Path(raw_path)
try:
metadata = path.lstat()
except OSError as error:
raise Forbidden("context identity key is unavailable") from error
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_uid != os.geteuid():
raise Forbidden("context identity key is unsafe")
try:
value = path.read_bytes()
except OSError as error:
raise Forbidden("context identity key is unavailable") from error
if len(value) != CONTEXT_KEY_BYTES:
raise Forbidden("context identity key is invalid")
return value
def _source(body: dict[str, Any], name: str) -> str:
value = body.get(name)
if not isinstance(value, str) or not RAW_RE.fullmatch(value):
raise Invalid(f"{name} is malformed")
return value
def derive_context_id(key: bytes, purpose: str, identity: Any, raw: str) -> str:
"""Derive one frozen HMAC-bound context id."""
prefix = {"session": "ses", "conversation": "conv", "project": "prj"}[purpose]
message = b"\0".join(
(CONTEXT_MESSAGE, purpose.encode(), identity.tenant_slot.encode(), identity.subject.encode(), raw.encode())
)
return f"{prefix}_{hmac.new(key, message, hashlib.sha256).hexdigest()[:32]}"
def _exact_body(request: Request) -> tuple[dict[str, Any], str, str]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
expected = {"raw_session_id", "project_source", "session_id", "conversation_id", "project_id"}
if set(request.body) != expected:
raise Invalid("context bootstrap fields are not exact")
raw_session = _source(request.body, "raw_session_id")
project_source = _source(request.body, "project_source")
for name, prefix in (("session_id", "ses"), ("conversation_id", "conv"), ("project_id", "prj")):
value = request.body.get(name)
if not isinstance(value, str) or not CONTEXT_ID_RE.fullmatch(value) or not value.startswith(f"{prefix}_"):
raise Invalid(f"{name} is malformed")
check_id(value)
return request.body, raw_session, project_source
def _verify_record(record: dict[str, Any], schema: str, owner: str, project_id: str | None = None) -> None:
if record.get("schema") != schema or record.get("owner") != owner:
raise Conflict("context identity does not match existing record")
if project_id is not None and record.get("project_id") != project_id:
raise Conflict("context linkage does not match existing record")
if contracts.validate_record(record, SCHEMAS):
raise Conflict("existing context record is invalid")
def _response(request: Request, project: dict[str, Any], conversation: dict[str, Any], created: dict[str, bool]) -> Response:
body = {
"schema": CONTEXT_SCHEMA,
"contract_version": CONTRACT_VERSION,
"identity": request.identity.record(),
"session_id": request.body["session_id"],
"conversation_id": conversation["id"],
"project_id": project["id"],
"created": created,
"revisions": {"project": project["revision"], "conversation": conversation["revision"]},
}
return Response(201 if any(created.values()) else 200, body)
def bootstrap_context(request: Request) -> Response:
"""Create deterministic server-owned project and conversation records for trusted runtimes."""
if request.identity.trust not in {"relay", "worker"}:
raise Forbidden("context bootstrap requires relay or worker trust")
body, raw_session, project_source = _exact_body(request)
idem = request.idempotency_key()
if not idem:
raise Invalid("Idempotency-Key is required")
key = _context_key(request.flags._environ)
expected = {
"session_id": derive_context_id(key, "session", request.identity, raw_session),
"conversation_id": derive_context_id(key, "conversation", request.identity, raw_session),
"project_id": derive_context_id(key, "project", request.identity, project_source),
}
if any(not hmac.compare_digest(body[name], value) for name, value in expected.items()):
raise Invalid("context identifiers do not match authenticated inputs")
with request.store.lock(CONTEXT_FAMILY):
binding = request.store.get(CONTEXT_FAMILY, body["session_id"]) if request.store.exists(CONTEXT_FAMILY, body["session_id"]) else None
if binding:
valid_binding = (
binding.get("schema") == "hux.context_binding.v1"
and binding.get("id") == body["session_id"]
and binding.get("owner") == request.identity.subject
and all(binding.get(name) == body[name] for name in expected)
)
if not valid_binding:
raise Conflict("context binding does not match existing linkage")
for row in request.store.read(CONTEXT_FAMILY, "idempotency"):
if row.get("key") == idem and any(row.get(name) != body[name] for name in expected):
raise Conflict("Idempotency-Key was used for another context")
project = request.store.get(organization.PROJECTS, body["project_id"]) if request.store.exists(organization.PROJECTS, body["project_id"]) else None
conversation = request.store.get(organization.CONVERSATIONS, body["conversation_id"]) if request.store.exists(organization.CONVERSATIONS, body["conversation_id"]) else None
if project:
_verify_record(project, "hux.project.v1", request.identity.subject)
if conversation:
_verify_record(conversation, "hux.conversation.v1", request.identity.subject, body["project_id"])
if project is None and request.store.count(organization.PROJECTS) >= organization.MAX_PROJECTS:
raise Conflict("projects cap reached")
if conversation is None and request.store.count(organization.CONVERSATIONS) >= organization.MAX_CONVERSATIONS:
raise Conflict("conversations cap reached")
stamp = now_iso()
created = {"project": project is None, "conversation": conversation is None}
project = project or request.store.put(organization.PROJECTS, organization.checked({
"schema": "hux.project.v1", "id": body["project_id"], "owner": request.identity.subject,
"name": "Hermes", "tags": [], "pinned": False, "archived": False, "default_mode": "fast",
"created_at": stamp, "updated_at": stamp, "revision": 1,
}))
conversation = conversation or request.store.put(organization.CONVERSATIONS, organization.checked({
"schema": "hux.conversation.v1", "id": body["conversation_id"], "owner": request.identity.subject,
"project_id": body["project_id"], "title": "Hermes conversation", "tags": [], "pinned": False,
"archived": False, "mode": "fast", "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
"revision": 1,
}))
if binding is None:
request.store.put(CONTEXT_FAMILY, {
"schema": "hux.context_binding.v1", "id": body["session_id"], "owner": request.identity.subject,
"session_id": body["session_id"], "conversation_id": body["conversation_id"],
"project_id": body["project_id"], "created_at": stamp,
})
if not any(row.get("key") == idem for row in request.store.read(CONTEXT_FAMILY, "idempotency")):
request.store.append(CONTEXT_FAMILY, "idempotency", {"key": idem, **expected})
request.audit("foundation.bootstrap", body["session_id"], reason="created" if any(created.values()) else "replayed")
return _response(request, project, conversation, created)
def capabilities(request: Request) -> Response:
"""``GET /hux/v1/capabilities``: what this tenant may call."""
request.audit("foundation.capabilities", "capabilities")
return Response(200, request.flags.capabilities(request.identity, request.flags_build))
def manifest(request: Request) -> Response:
"""``GET /hux/v1/manifest``: the on-disk layout version for rollback readers."""
request.audit("foundation.manifest", "manifest")
return Response(200, request.store.manifest(CONTRACT_VERSION))
def register(router: Router) -> None:
"""Attach HUX-11 routes."""
router.add("GET", "/hux/v1/capabilities", "HUX-11", "foundation.capabilities", capabilities)
router.add("GET", "/hux/v1/manifest", "HUX-11", "foundation.manifest", manifest)
router.add("POST", "/hux/v1/context/bootstrap", "HUX-11", "foundation.bootstrap", bootstrap_context, 4096)

View File

@ -0,0 +1,359 @@
"""Minimal HTTP layer for the per-tenant HUX service.
Stdlib only. A ``Router`` maps method + path template to a handler; family
modules register their routes with it. Every request resolves identity from
trusted headers, checks the owning card's flag, and writes an audit outcome.
Errors always leave as ``hux.error.v1``.
"""
from __future__ import annotations
import json
import math
import os
import re
import threading
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlsplit
from hux import audit
from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, TooLarge
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
from hux.identity import Identity, resolve
from hux.store import TenantStore
MAX_BODY_BYTES = 1024 * 1024
MAX_QUERY_FIELDS = 64
DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
DEFAULT_READS_PER_MINUTE = 300
DEFAULT_WRITES_PER_MINUTE = 30
MAX_RATE_BUCKETS = 4096
Handler = Callable[["Request"], "Response"]
def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float:
"""Read one numeric setting, using its safe default when malformed or outside bounds."""
try:
value = float(environ.get(name, default))
except (TypeError, ValueError):
return default
return value if math.isfinite(value) and low <= value <= high else default
class RateLimiter:
"""Small fixed-window limiter bounded by active subjects in the current minute."""
def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None:
self.limits = {"read": reads, "write": writes}
self.clock = clock
self._windows: dict[tuple[str, str], tuple[float, int]] = {}
self._lock = threading.Lock()
def check(self, subject: str, method: str) -> int | None:
"""Consume one request and return Retry-After seconds when its bucket is full."""
now = self.clock()
bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write"
key = (subject, bucket)
with self._lock:
# Each accepted identity can leave at most two current entries;
# expired identities disappear whenever any request arrives.
self._windows = {item: value for item, value in self._windows.items() if value[0] > now}
reset, used = self._windows.get(key, (now + 60.0, 0))
if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS:
return 60
if used >= self.limits[bucket]:
return max(1, math.ceil(reset - now))
self._windows[key] = (reset, used + 1)
return None
@dataclass
class Request:
"""Everything a handler needs; no raw socket access."""
method: str
path: str
params: dict[str, str]
query: dict[str, str]
headers: Mapping[str, str]
body: Any
identity: Identity
store: TenantStore
flags: Flags
flags_build: dict[str, str] = field(default_factory=dict)
def if_match(self) -> int | None:
"""Parsed If-Match revision, or None when absent."""
raw = self.header("If-Match")
if raw == "":
return None
if not raw.isdigit():
raise Invalid("If-Match must be a revision integer")
return int(raw)
def idempotency_key(self) -> str:
"""Client idempotency key, validated against the contract pattern."""
raw = self.header("Idempotency-Key")
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
raise Invalid("malformed Idempotency-Key")
return raw
def header(self, name: str) -> str:
"""Case-insensitive header lookup."""
for key, value in self.headers.items():
if key.lower() == name.lower():
return value.strip()
return ""
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
"""Write an audit outcome for this request."""
audit.record(self.store, self.identity, action, resource, outcome, reason)
@dataclass
class Response:
"""JSON (or SSE) response."""
status: int = 200
body: Any = None
headers: dict[str, str] = field(default_factory=dict)
stream: Callable[[], Any] | None = None
@dataclass
class Route:
"""One registered handler."""
method: str
template: str
card: str
action: str
handler: Handler
max_body: int = MAX_BODY_BYTES
pattern: re.Pattern = field(init=False)
def __post_init__(self) -> None:
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
self.pattern = re.compile(f"^{regex}$")
class Router:
"""Route table plus the request pipeline."""
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
self.data_root = Path(data_root)
self.environ = dict(os.environ if environ is None else environ)
self.flags = Flags(self.environ)
self.build = build_from_environ(self.environ)
self.routes: list[Route] = []
reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000))
writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000))
self.request_timeout = _bounded_number(
self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0
)
self.rate_limiter = RateLimiter(reads, writes)
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
self.routes.append(Route(method, template, card, action, handler, max_body))
def bind_capability_routes(self) -> None:
"""Make feature negotiation depend on the route templates this process really registered."""
routes: dict[str, set[str]] = {}
for route in self.routes:
routes.setdefault(route.card, set()).add(route.template)
self.flags.bind_routes(routes)
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
"""Return (route, params, path_known)."""
known = False
for route in self.routes:
found = route.pattern.match(path)
if found:
known = True
if route.method == method:
return route, found.groupdict(), True
return None, {}, known
def body_limit(self, method: str, raw_path: str) -> int:
"""Maximum declared body for a matching route, before a socket read allocates it."""
route, _, _ = self.match(method, urlsplit(raw_path).path)
return route.max_body if route is not None else MAX_BODY_BYTES
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
"""Run the full pipeline and never raise."""
parts = urlsplit(raw_path)
route: Route | None = None
try:
identity = resolve(headers, self.environ)
store = TenantStore(self.data_root, identity)
except HuxError as error:
return Response(error.status, error.record())
try:
retry_after = self.rate_limiter.check(identity.subject, method)
if retry_after is not None:
raise RateLimited(retry_after)
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
# SO-08: the worker allowlist is checked before the flag so a
# worker cannot even learn which cards are on.
if identity.trust == "worker" and not worker_may_call(method, route.template):
raise Forbidden("route is not available to worker trust")
if identity.trust == "evidence":
allowed = {
("GET", "/hux/v1/capabilities"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"),
}
if (method, route.template) not in allowed:
raise Forbidden("route is not available to evidence trust")
self.flags.require(route.card)
try:
parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS)
except ValueError as error:
raise Invalid("query has too many fields") from error
query = {key: values[-1] for key, values in parsed_query.items()}
payload = self._decode(body, route.max_body)
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
response = route.handler(request)
except HuxError as error:
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, outcome, error.message)
headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {}
return Response(error.status, error.record(), headers)
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
error = HuxError("internal error")
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, "deny", error.message)
return Response(error.status, error.record())
return response
@staticmethod
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
if not body:
return None
if len(body) > max_body:
raise TooLarge(f"body exceeds {max_body} bytes")
try:
return json.loads(body)
except json.JSONDecodeError as error:
raise Invalid(f"body is not JSON: {error.msg}") from error
def page(items: list[Any], next_cursor: Any = None) -> Response:
"""Standard list envelope."""
return Response(200, {"items": items, "next": next_cursor})
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
class HuxHandler(BaseHTTPRequestHandler):
server_version = "hux-foundation/1.0"
def log_message(self, fmt: str, *args: Any) -> None:
return
def _run(self) -> None:
try:
if self.headers.get("Transfer-Encoding"):
raise Invalid("Transfer-Encoding is not supported")
lengths = self.headers.get_all("Content-Length", [])
if len(lengths) > 1:
raise Invalid("duplicate Content-Length is not supported")
raw_length = lengths[0] if lengths else "0"
if not raw_length.isascii() or not raw_length.isdigit():
raise Invalid("Content-Length must be a non-negative integer")
if len(raw_length) > 10:
raise TooLarge("declared body length is too large")
length = int(raw_length)
limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path)
if length > limit:
raise TooLarge(f"body exceeds {limit} bytes")
body = self.rfile.read(length) if length else b""
if len(body) != length:
raise Invalid("request body is incomplete or timed out")
if self.path == "/healthz":
body = {"status": "ok", "contract_version": CONTRACT_VERSION}
scheduler = getattr(router, "retention_scheduler", None)
if scheduler is not None:
body["retention"] = scheduler.health()
self._send(Response(200, body))
return
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
except HuxError as error:
self._send(Response(error.status, error.record()))
except (OSError, TimeoutError):
error = Invalid("request body is incomplete or timed out")
self._send(Response(error.status, error.record()))
def _send(self, response: Response) -> None:
if response.stream is not None:
self.send_response(response.status)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
for chunk in response.stream():
self.wfile.write(chunk)
self.wfile.flush()
return
data = json.dumps(response.body, sort_keys=True).encode()
self.send_response(response.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
return HuxHandler
class BoundedHTTPServer(ThreadingHTTPServer):
"""Threading server that bounds header and body socket reads from accept onward."""
def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float, scheduler: Any = None) -> None:
self.request_timeout = timeout
self.scheduler = scheduler
super().__init__(address, handler)
def get_request(self) -> tuple[Any, Any]:
"""Accept one connection and apply its per-request socket deadline."""
request, address = super().get_request()
request.settimeout(self.request_timeout)
return request, address
def server_close(self) -> None:
"""Stop background work before closing the listening socket."""
if self.scheduler is not None:
self.scheduler.stop()
super().server_close()
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
"""Create (but do not start) the server; callers call serve_forever()."""
from hux.retention_scheduler import RetentionScheduler
scheduler = RetentionScheduler(router)
router.retention_scheduler = scheduler
server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout, scheduler)
server.daemon_threads = True
scheduler.start()
return server

View File

@ -0,0 +1,196 @@
"""Caller identity resolved from the trusted hop's headers.
The chat router, the Telegram relay and the Worker are the only callers. Each
asserts identity in headers; the request body is never trusted for identity.
Router, relay, and worker callers must each present their distinct shared key,
compared in constant time against an inline or projected secret value.
"""
from __future__ import annotations
import hmac
import os
import re
import stat
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from hux.errors import Unauthorized
HEADER_SLOT = "X-Hermes-Tenant-Identity"
HEADER_SUBJECT = "X-Hux-Subject"
HEADER_SURFACE = "X-Hux-Surface"
HEADER_TRUST = "X-Hux-Trust"
HEADER_KEY = "X-Hux-Relay-Key"
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
SURFACES = ("chat", "worker", "telegram", "voice", "api")
TRUSTS = ("router", "relay", "worker", "evidence")
KEY_ENV = {
"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY",
"worker": "HUX_WORKER_KEY", "evidence": "HUX_RELEASE_EVIDENCE_KEY",
}
MAX_KEY_BYTES = 4096
MAX_SUBJECT_BYTES = 128
SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE"
@dataclass(frozen=True)
class Identity:
"""Who is calling, on which surface, asserted by which trusted hop."""
tenant_slot: str
subject: str
surface: str
trust: str
def record(self) -> dict[str, str]:
"""Serialise as ``common.identity``."""
return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust}
def _header(headers: Mapping[str, str], name: str) -> str:
for key, value in headers.items():
if key.lower() == name.lower():
return value.strip()
return ""
def _expected_key(environ: Mapping[str, str], trust: str) -> str:
"""Read a caller key, preferring a bounded Kubernetes secret file."""
if trust == "evidence":
from hux.release_security import evidence_key
try:
return evidence_key(environ)
except Exception: # noqa: BLE001 - authentication fails closed without revealing configuration detail
return ""
name = KEY_ENV[trust]
key_file = environ.get(f"{name}_FILE", "")
if key_file:
try:
path = Path(key_file)
mode = path.stat().st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
return ""
data = path.read_bytes()
except OSError:
return ""
if len(data) > MAX_KEY_BYTES:
return ""
try:
return data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError:
return ""
return environ.get(name, "")
def _read_subject_binding(path: Path) -> str | None:
"""Read a complete, permission-constrained binding without following links."""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
return None
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
try:
mode = os.fstat(descriptor).st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
raise Unauthorized("subject binding is invalid")
with os.fdopen(descriptor, "rb", closefd=False) as binding:
data = binding.read(MAX_SUBJECT_BYTES + 1)
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
os.close(descriptor)
if not data or len(data) > MAX_SUBJECT_BYTES:
raise Unauthorized("subject binding is invalid")
try:
value = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise Unauthorized("subject binding is invalid") from error
if not SUBJECT_RE.fullmatch(value):
raise Unauthorized("subject binding is invalid")
return value
def _publish_subject_binding(path: Path, subject: str) -> None:
"""Publish an immutable first-writer binding; concurrent writers never see partial data."""
descriptor = -1
temporary = ""
try:
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
os.fchmod(descriptor, 0o440)
with os.fdopen(descriptor, "wb", closefd=False) as binding:
binding.write((subject + "\n").encode("utf-8"))
binding.flush()
os.fsync(descriptor)
os.close(descriptor)
descriptor = -1
try:
os.link(temporary, path, follow_symlinks=False)
except FileExistsError:
pass
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if temporary:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
except OSError:
# The binding decision is still verified by a fresh read below.
pass
def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None:
"""Bind from an authenticated edge hop, then require every caller to match."""
raw_path = environ.get(SUBJECT_BINDING_ENV, "")
if not raw_path:
return
path = Path(raw_path)
bound = _read_subject_binding(path)
if bound is None:
if trust == "worker":
raise Unauthorized("worker subject is not bound")
_publish_subject_binding(path, subject)
bound = _read_subject_binding(path)
if bound is None or not hmac.compare_digest(bound, subject):
raise Unauthorized("subject does not match trusted binding")
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
"""Build an Identity from request headers or raise Unauthorized.
``trust`` defaults to ``router``; every trusted hop requires its distinct
shared key to be configured and presented.
"""
environ = os.environ if environ is None else environ
slot = _header(headers, HEADER_SLOT)
subject = _header(headers, HEADER_SUBJECT)
surface = _header(headers, HEADER_SURFACE) or "chat"
trust = _header(headers, HEADER_TRUST) or "router"
if not SLOT_RE.match(slot):
raise Unauthorized("missing or malformed tenant slot")
own_slot = environ.get("HUX_TENANT_SLOT", "")
if own_slot and slot != own_slot:
raise Unauthorized("tenant slot does not belong to this pod")
if not SUBJECT_RE.match(subject):
raise Unauthorized("missing or malformed subject")
if surface not in SURFACES or trust not in TRUSTS:
raise Unauthorized("unknown surface or trust")
expected = _expected_key(environ, trust)
presented = _header(headers, HEADER_KEY)
if not expected or not presented or not hmac.compare_digest(expected, presented):
raise Unauthorized(f"{trust} key missing or wrong")
if trust == "router" and surface == "worker":
raise Unauthorized("worker surface needs worker trust")
if trust == "evidence" and surface != "api":
raise Unauthorized("evidence trust needs api surface")
_enforce_subject_binding(environ, trust, subject)
return Identity(slot, subject, surface, trust)

View File

@ -0,0 +1,459 @@
"""HUX-02 memory control: an append-only ledger with explicit user consent.
Every entry is a ``hux.memory.v1`` snapshot. The newest snapshot per id is
kept as a revisioned document (so If-Match works) and every snapshot is also
appended to ``ledger.jsonl``. Status moves only along
``rules.MEMORY_TRANSITIONS``; ``no_store`` and ``forgotten`` write a
content-free line plus a tombstone before anything is returned (SO-22), and
``retrieve`` consults the tombstones before the documents (SO-23).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, redaction, rules
from hux.errors import Conflict, Forbidden, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, new_id, now_iso
FAMILY = "memory"
IDEM_FAMILY = "memory_idem"
LEDGER = "ledger"
TOMBSTONES = "tombstones"
DEFAULT_CONVERSATION = "conv_memory"
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
ACTIONS = ("approve", "reject", "forget", "edit", "remove_retrieval", "restore_retrieval")
KINDS = ("preference", "fact", "instruction", "context")
DEFAULT_DECAY_DAYS = 180
SCHEMAS = contracts.load_all()
def _parse(stamp: str) -> datetime:
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def _emit(store: TenantStore, identity: Identity, record: dict[str, Any], kind: str, summary: str) -> None:
from hux import events
detail = {"memory_id": record["id"], "kind": record["kind"], "sensitivity": record["sensitivity"], "topic": record.get("topic", "general")}
conversation_id = record["provenance"].get("conversation_id", DEFAULT_CONVERSATION)
events.emit(store, identity, conversation_id, kind, summary, detail, [{"kind": "memory", "id": record["id"]}], sensitivity=record["sensitivity"])
def tombstoned(store: TenantStore) -> set[str]:
"""Ids that must never surface again, whatever the documents say."""
return {row["memory_id"] for row in store.read(FAMILY, TOMBSTONES)}
def _tombstone(store: TenantStore, memory_id: str, reason: str) -> None:
store.append(FAMILY, TOMBSTONES, {"memory_id": memory_id, "at": now_iso(), "reason": reason, "purged": False})
def effective_expiry(record: dict[str, Any]) -> datetime | None:
"""When the entry stops being active: ``expires_at``, or decay from the last approval (or creation)."""
ttl = record["ttl"]
if ttl["policy"] == "expires_at":
return _parse(ttl["expires_at"])
if ttl["policy"] == "decay":
approvals = [row["at"] for row in record["audit"] if row["action"] == "approved"]
return _parse(approvals[-1] if approvals else record["created_at"]) + timedelta(days=ttl["decay_days"])
return None
def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
"""Validate against contract and rules, then write document + ledger line under the family lock."""
problems = rules.memory_policy_violations(record) + contracts.validate_record({**record, "revision": record.get("revision") or 1}, SCHEMAS)
if problems:
raise Invalid("memory entry violates policy", problems)
with store.lock(FAMILY):
stored = store.put(FAMILY, record, expected)
store.append(FAMILY, LEDGER, stored)
return stored
def _transition(store: TenantStore, identity: Identity, record: dict[str, Any], target: str, action: str, actor: dict[str, str],
note: str = "", expected: int | None = None, **changes: Any) -> dict[str, Any]:
"""Move an entry to ``target`` from its *current* stored state (F7).
The caller's ``record`` may be stale: the entry is re-read under the family
lock, ``expected`` (If-Match) is checked against that copy, and the write is
always conditional on the loaded revision so a stale unconditional write can
never resurrect a forgotten entry (SO-22, SO-44).
"""
with store.lock(FAMILY):
record = _current(store, record, expected)
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
stamp = now_iso()
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
if target in {"forgotten", "rejected", "expired"}:
updated["content"], updated["retrievable"] = "", False
if target == "active":
updated["retrievable"] = changes.get("retrievable", True)
stored = _persist(store, updated, record["revision"])
if target == "forgotten":
_tombstone(store, stored["id"], action)
return stored
def _current(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
"""Fresh copy of ``record`` from the store; Conflict when If-Match no longer matches it."""
current = store.get(FAMILY, record["id"])
if expected is not None and expected != current["revision"]:
raise Conflict(f"revision {expected} does not match current revision {current['revision']}", [str(current["revision"])])
return current
def load(store: TenantStore, memory_id: str, now: datetime | None = None) -> dict[str, Any]:
"""Newest snapshot with lazy expiry (SO-27): an entry past its expiry is written as expired before it is served."""
record = store.get(FAMILY, memory_id)
if record["status"] == "active":
expiry = effective_expiry(record)
if expiry is not None and expiry <= (now or datetime.now(timezone.utc)):
record = _transition(store, None, record, "expired", "expired", {"type": "system", "id": "retention"}, expected=record["revision"])
return record
def expire_due(store: TenantStore, now: datetime) -> int:
"""Retention pass: move every active entry past its expiry to expired. Returns the count."""
before = {row["id"]: row["status"] for row in store.scan(FAMILY)}
return sum(1 for memory_id, status in before.items() if status == "active" and load(store, memory_id, now)["status"] == "expired")
def purge_forgotten(store: TenantStore) -> int:
"""Retention pass (SO-47): blank ``content`` on every ledger snapshot of tombstoned ids, then mark them purged."""
with store.lock(FAMILY):
stones = store.read(FAMILY, TOMBSTONES)
pending = {row["memory_id"] for row in stones if not row.get("purged")}
if not pending:
return 0
rows = store.read(FAMILY, LEDGER)
store.rewrite(FAMILY, LEDGER, [{**row, "content": ""} if row.get("id") in pending else row for row in rows])
store.rewrite(FAMILY, TOMBSTONES, [{**row, "purged": True} for row in stones])
return len(pending)
def forget_from_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> list[str]:
"""Forget every entry sourced from ``conversation_id`` (used by the privacy forget); returns the ids."""
from hux import events
forgotten: list[str] = []
for row in list(store.scan(FAMILY)):
if row["provenance"].get("conversation_id") != conversation_id:
continue
if row["status"] in {"proposed"}:
row = _transition(store, identity, row, "rejected", "rejected", {"type": "system", "id": "forget"}, "conversation forgotten")
if row["status"] in {"active", "expired"}:
row = _transition(store, identity, row, "forgotten", "forgotten", {"type": "system", "id": "forget"}, "conversation forgotten")
if row["status"] not in {"forgotten", "no_store"}:
_tombstone(store, row["id"], "conversation forgotten")
forgotten.append(row["id"])
events.redact_memory_references(store, row["id"])
return forgotten
def retrieve(store: TenantStore, query_terms: list[str], scope: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""Agent read hook (SO-23): tombstones first, then only active + retrievable entries in scope; never no_store, forgotten or expired."""
from hux import privacy
stones = tombstoned(store)
blocked = privacy.blocked_conversations(store)
if scope and scope.get("level") == "conversation" and scope.get("scope_id") in blocked:
return []
terms = [term.lower() for term in query_terms if term]
hits: list[dict[str, Any]] = []
for row in store.scan(FAMILY):
if row["id"] in stones or row["provenance"].get("conversation_id") in blocked:
continue
record = load(store, row["id"])
if record["status"] != "active" or not record["retrievable"]:
continue
if scope and record["scope"]["level"] != "global" and record["scope"] != scope:
continue
text = record["content"].lower()
if terms and not any(term in text for term in terms):
continue
hits.append(record)
return sorted(hits, key=lambda r: r["updated_at"], reverse=True)
# -- proposals ------------------------------------------------------------------
def _human(identity: Identity) -> bool:
return identity.surface in HUMAN_SURFACES and identity.trust != "worker"
def _actor(request: Request) -> dict[str, str]:
if _human(request.identity) and request.body.get("proposed_by", "assistant") == "user":
return {"type": "user", "id": request.identity.subject}
return {"type": "assistant", "id": "hermes"}
def _classify(store: TenantStore, body: dict[str, Any], content: str, conversation_id: str | None) -> dict[str, Any]:
"""Scrub ``content`` and decide topic, sensitivity floor and the privacy gate; shared by create and edit (F3)."""
from hux import privacy
hits: list[str] = []
content = redaction.scrub_value(content.strip(), hits)[:2000]
detected = privacy.detect_topics(content) + (["credentials"] if hits else [])
topic = body.get("topic") if body.get("topic") in rules.PRIVACY_TOPICS or body.get("topic") == "general" else None
topic = topic if topic and topic != "general" else (detected[0] if detected else "general")
sensitivity = body.get("sensitivity", "personal")
if sensitivity not in privacy.SENSITIVITY_RANK:
raise Invalid("unknown sensitivity")
floor = privacy.topic_sensitivity(detected + ([topic] if topic != "general" else []))
if privacy.SENSITIVITY_RANK[floor] > privacy.SENSITIVITY_RANK[sensitivity]:
sensitivity = floor
allowed, why = privacy.memory_write_allowed(store, conversation_id, {"sensitivity": sensitivity, "topic": topic})
if why == "private_mode":
raise Forbidden("memory writes are refused in private mode")
return {"content": content, "topic": topic, "sensitivity": sensitivity, "allowed": allowed, "why": why}
def _decline(entry: dict[str, Any], why: str, actor: dict[str, str], stamp: str) -> dict[str, Any]:
"""Content-free ``no_store`` decision record.
The topic moves into the note because rules.memory_policy_violations reads a
deny topic on any non-rejected status as a write, and a sensitive entry must
say approval_mode=ask.
"""
topic = entry.pop("topic")
entry.update(status="no_store", approval_mode="ask" if entry["sensitivity"] == "sensitive" else "no_store", content="", retrievable=False,
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
return entry
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
if "id" in body or "revision" in body or "status" in body:
raise Invalid("id, revision and status are server-assigned")
content = body.get("content")
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
raise Invalid("content must be a non-empty string of at most 2000 chars")
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
verdict = _classify(request.store, body, content, conversation_id)
topic, sensitivity = verdict["topic"], verdict["sensitivity"]
scope = body.get("scope") if isinstance(body.get("scope"), dict) else {"level": "global"}
source = body.get("source") if isinstance(body.get("source"), dict) else {"kind": "message", "id": "unspecified"}
ttl = body.get("ttl") if isinstance(body.get("ttl"), dict) else None
if ttl is None or (sensitivity == "sensitive" and ttl.get("policy") == "never"):
days = rules.PRIVACY_TOPICS[topic]["decay_days"] if topic in rules.PRIVACY_TOPICS else DEFAULT_DECAY_DAYS
ttl = {"policy": "decay", "decay_days": days}
actor = _actor(request)
stamp = now_iso()
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
if conversation_id:
provenance["conversation_id"] = conversation_id
if isinstance(body.get("run_id"), str):
provenance["run_id"] = body["run_id"][:120]
entry: dict[str, Any] = {
"schema": "hux.memory.v1", "id": new_id("mem"), "owner": request.identity.subject, "scope": scope,
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": verdict["content"], "status": "proposed",
"approval_mode": "ask", "sensitivity": sensitivity, "topic": topic, "ttl": ttl, "source": source,
"provenance": provenance, "created_at": stamp, "updated_at": stamp,
"audit": [{"at": stamp, "action": "proposed", "actor": actor}], "reason": str(body.get("reason") or "Proposed to be remembered.")[:280],
"retrievable": False, "identity": request.identity.record(),
}
if isinstance(body.get("supersedes"), str):
entry["supersedes"] = body["supersedes"]
references_forgotten = entry.get("supersedes") in tombstoned(request.store) or source.get("id") in tombstoned(request.store)
wants = body.get("approval_mode", "ask" if actor["type"] == "assistant" else "automatic")
why = verdict["why"]
if not verdict["allowed"] or wants == "no_store":
why = why if not verdict["allowed"] else "declined"
_decline(entry, why, actor, stamp)
elif sensitivity == "sensitive" or wants == "ask" or references_forgotten or actor["type"] != "user":
entry["approval_mode"] = "ask"
else:
entry.update(status="active", approval_mode="automatic", retrievable=True)
entry["audit"].append({"at": stamp, "action": "approved", "actor": actor, "note": "automatic"})
return entry, why
def _replay(request: Request, key: str) -> Response | None:
for row in request.store.read(IDEM_FAMILY, "keys"):
if row["idempotency_key"] == key:
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
return None
def post_memory(request: Request) -> Response:
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202).
The Idempotency-Key lookup, the write and the key mapping all happen under
the family lock so concurrent retries with one key yield one record (F13b).
"""
body = request.body if isinstance(request.body, dict) else None
if body is None:
raise Invalid("body must be an object")
key = request.idempotency_key()
with request.store.lock(FAMILY):
replayed = _replay(request, key) if key else None
if replayed is not None:
return replayed
entry, why = _shape(request, body)
try:
stored = _persist(request.store, entry, None)
except Invalid:
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
raise
if key:
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
if stored["status"] == "no_store":
_tombstone(request.store, stored["id"], why)
_emit(request.store, request.identity, stored, "memory.suppressed", f"Not remembered ({why})")
request.audit("memory.propose", stored["id"], "allow", "no_store")
return Response(202, stored)
kind = "memory.committed" if stored["status"] == "active" else "memory.proposed"
_emit(request.store, request.identity, stored, kind, "Remembered" if stored["status"] == "active" else "Proposed to remember")
request.audit("memory.propose", stored["id"])
return Response(201, stored)
def list_memory(request: Request) -> Response:
"""``GET /hux/v1/memory?status=&scope=``: owner-scoped list; ``scope`` is ``level`` or ``level:scope_id``."""
status, scope = request.query.get("status"), request.query.get("scope")
level, _, scope_id = (scope or "").partition(":")
items = []
for row in list(request.store.scan(FAMILY)):
record = load(request.store, row["id"])
if status and record["status"] != status:
continue
if level and (record["scope"]["level"] != level or (scope_id and record["scope"].get("scope_id") != scope_id)):
continue
items.append(record)
request.audit("memory.list", "memory")
return page(sorted(items, key=lambda r: r["updated_at"], reverse=True), None)
def get_memory(request: Request) -> Response:
"""``GET /hux/v1/memory/{id}``."""
memory_id = request.params["id"]
try:
record = load(request.store, memory_id)
except (NotFound, Invalid) as error:
raise NotFound("memory entry not found") from error
request.audit("memory.get", memory_id)
return Response(200, record, {"ETag": str(record["revision"])})
def _edit(request: Request, record: dict[str, Any], actor: dict[str, str], expected: int | None) -> dict[str, Any]:
"""Supersede ``record`` with edited content under the same privacy shaping as a proposal (F3).
A deny verdict (deny topic, restricted, memory disabled, conversation
forgotten) yields a content-free ``no_store`` record and leaves the old
entry untouched; a sensitive floor makes the replacement ``proposed`` with
``approval_mode: ask``. Private mode is refused outright.
"""
body = request.body if isinstance(request.body, dict) else {}
content = body.get("content")
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
raise Invalid("edit needs new content of at most 2000 chars")
conversation_id = record["provenance"].get("conversation_id")
verdict = _classify(request.store, {**body, "sensitivity": record["sensitivity"]}, content, conversation_id)
stamp = now_iso()
fresh = {
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": verdict["content"],
"topic": verdict["topic"], "sensitivity": verdict["sensitivity"], "status": "active", "approval_mode": "automatic", "retrievable": True,
"created_at": stamp, "updated_at": stamp, "audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"edit of {record['id']}"}],
}
if not verdict["allowed"]:
stored = _persist(request.store, _decline(fresh, verdict["why"], actor, stamp), None)
_tombstone(request.store, stored["id"], verdict["why"])
return stored
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
from hux import events
events.redact_memory_references(request.store, old["id"])
fresh["supersedes"] = old["id"]
fresh["audit"][0]["note"] = f"supersedes {old['id']}"
if fresh["sensitivity"] == "sensitive":
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
else:
fresh["audit"].append({"at": stamp, "action": "approved", "actor": actor})
return _persist(request.store, fresh, None)
def _set_retrievable(store: TenantStore, record: dict[str, Any], retrievable: bool, actor: dict[str, str], expected: int | None) -> dict[str, Any]:
"""Flip ``retrievable`` on an active entry, re-reading it under the lock so a stale copy cannot overwrite a forget (F7)."""
with store.lock(FAMILY):
record = _current(store, record, expected)
if record["status"] != "active":
raise Conflict("retrieval can only change on active entries")
stamp = now_iso()
entry = {"at": stamp, "action": "approved" if retrievable else "retrieval_removed", "actor": actor, "note": "retrieval restored" if retrievable else ""}
return _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], entry]}, record["revision"])
def act_memory(request: Request) -> Response:
"""``POST /hux/v1/memory/{id}/{action}``: approve, reject, forget, edit, remove_retrieval, restore_retrieval; every move emits a memory.* event."""
memory_id, action = request.params["id"], request.params["action"]
if action not in ACTIONS:
raise NotFound("unknown memory action")
if not _human(request.identity):
raise Forbidden("memory decisions come from a human surface")
try:
record = load(request.store, memory_id)
except (NotFound, Invalid) as error:
raise NotFound("memory entry not found") from error
expected = request.if_match()
if expected is not None and expected != record["revision"]:
raise Conflict(f"revision {expected} does not match current revision {record['revision']}", [str(record["revision"])])
actor = {"type": "user", "id": request.identity.subject}
store, identity = request.store, request.identity
if action == "approve":
stored = _transition(store, identity, record, "active", "approved", actor, expected=expected)
_emit(store, identity, stored, "memory.committed", "Memory approved")
elif action == "reject":
stored = _transition(store, identity, record, "rejected", "rejected", actor, expected=expected)
_emit(store, identity, stored, "memory.suppressed", "Memory rejected")
elif action == "forget":
stored = _transition(store, identity, record, "forgotten", "forgotten", actor, expected=expected)
from hux import events
events.redact_memory_references(store, stored["id"])
_emit(store, identity, stored, "memory.forgotten", "Memory forgotten")
elif action == "edit":
stored = _edit(request, record, actor, expected)
if stored["status"] == "no_store":
_emit(store, identity, stored, "memory.suppressed", f"Edit not remembered ({stored['audit'][0]['note'].split(';')[0]})")
request.audit("memory.edit", memory_id, "allow", "no_store")
return Response(202, stored)
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
else:
retrievable = action == "restore_retrieval"
stored = _set_retrievable(store, record, retrievable, actor, expected)
_emit(store, identity, stored, "memory.retrieval_removed" if not retrievable else "memory.committed", "Retrieval removed" if not retrievable else "Retrieval restored")
request.audit(f"memory.{action}", memory_id, "allow", "" if expected is not None else "unconditional_write")
return Response(200, stored, {"ETag": str(stored["revision"])})
def export_memory(request: Request) -> Response:
"""``GET /hux/v1/memory/export``: active, retrievable entries only, each stamped ``exported`` (SO-26)."""
stones = tombstoned(request.store)
items = []
for row in list(request.store.scan(FAMILY)):
record = load(request.store, row["id"])
if record["id"] in stones or record["status"] != "active" or not record["retrievable"]:
continue
stamp = now_iso()
items.append(_persist(request.store, {**record, "audit": [*record["audit"], {"at": stamp, "action": "exported", "actor": {"type": "user", "id": request.identity.subject}}]}, record["revision"]))
request.store.append(FAMILY, "exports", {"at": now_iso(), "ids": [item["id"] for item in items]})
request.audit("memory.export", "memory")
response = page(items, None)
response.headers["Content-Disposition"] = 'attachment; filename="hux-memory-export.json"'
return response
def register(router: Router) -> None:
"""Attach HUX-02 routes."""
router.add("GET", "/hux/v1/memory/export", "HUX-02", "memory.export", export_memory)
router.add("POST", "/hux/v1/memory", "HUX-02", "memory.propose", post_memory)
router.add("GET", "/hux/v1/memory", "HUX-02", "memory.list", list_memory)
router.add("GET", "/hux/v1/memory/{id}", "HUX-02", "memory.get", get_memory)
router.add("POST", "/hux/v1/memory/{id}/{action}", "HUX-02", "memory.act", act_memory)

View File

@ -0,0 +1,169 @@
"""HUX-06 provider-neutral friendly modes and scoped route selection.
Automatic modes express intent to Switchyard and never name a provider. An
exact route can only be selected through the explicit advanced path and must
exist in the operator-provided route catalog. Private mode is local-only.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from typing import Any
from hux import contracts, rules
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import now_iso
CARD = "HUX-06"
FAMILY = "mode_selections"
MODE_NAMES = ("fast", "thoughtful", "research", "create", "private")
MANUAL_ROUTE = re.compile(r"^atlas/manual/(codex|claude|local)/[a-z0-9][a-z0-9/-]{0,100}$")
MAX_CATALOG_ROUTES = 256
SCHEMAS = contracts.load_all()
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - {"project_id", "mode", "advanced", "override_route_id"}
if extra:
raise Invalid("unexpected mode fields", sorted(extra))
return request.body
def _scope(request: Request, project_id: Any) -> tuple[dict[str, Any], dict[str, Any]]:
"""Resolve a conversation and its mandatory owning project."""
from hux import organization
try:
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
except (Invalid, NotFound) as error:
raise NotFound("conversation not found") from error
path_project = request.params.get("project_id")
actual = conversation.get("project_id")
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
raise NotFound("project or conversation not found")
try:
project = request.store.get(organization.PROJECTS, project_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
return project, conversation
def _record_id(conversation_id: str) -> str:
digest = hashlib.sha256(conversation_id.encode()).hexdigest()[:24]
return f"mode_{digest}"
def _catalog() -> set[str]:
raw = os.environ.get("HUX_SWITCHYARD_ROUTE_CATALOG", "")
items = [item.strip() for item in raw.split(",") if item.strip()]
if len(items) > MAX_CATALOG_ROUTES:
return set()
return {item for item in items if MANUAL_ROUTE.fullmatch(item)}
def _contract(mode: str, advanced: bool, override: Any) -> dict[str, Any]:
if not isinstance(mode, str) or mode not in MODE_NAMES:
raise Invalid("unknown friendly mode")
if override is not None:
if not advanced or not isinstance(override, str) or not MANUAL_ROUTE.fullmatch(override):
raise Invalid("an exact route requires a valid advanced manual route")
if override not in _catalog():
raise Invalid("advanced route is not in the Switchyard catalog")
if mode == "private" and not override.startswith("atlas/manual/local/"):
raise Invalid("private mode is local-only")
elif advanced:
raise Invalid("advanced selection requires override_route_id")
try:
result = rules.mode_contract(mode, override)
except ValueError as error:
raise Invalid(str(error)) from error
problems = contracts.validate("mode.schema.json", result, SCHEMAS)
if problems:
raise Invalid("mode failed contract validation", problems)
if mode != "private" and result["switchyard"]["route_id"].startswith("atlas/manual/"):
raise Invalid("automatic modes may not pin a provider")
return result
def _fingerprint(body: dict[str, Any]) -> str:
allowed = {key: body.get(key) for key in ("project_id", "mode", "advanced", "override_route_id")}
return hashlib.sha256(json.dumps(allowed, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different selection")
return request.store.get(FAMILY, row["id"])
return None
def list_modes(request: Request) -> Response:
"""Return all provider-neutral mode contracts."""
items = [_contract(name, False, None) for name in MODE_NAMES]
request.audit("modes.list", "modes")
return page(items)
def get_selection(request: Request) -> Response:
"""Return the selected mode for one bound conversation."""
project_id = request.params["project_id"]
_, conversation = _scope(request, project_id)
record = request.store.get(FAMILY, _record_id(conversation["id"]))
request.audit("modes.read", record["id"])
return Response(200, record, {"ETag": str(record["revision"]), "Cache-Control": "no-store"})
def put_selection(request: Request) -> Response:
"""Select a mode using If-Match and Idempotency-Key."""
body = _body(request)
_, conversation = _scope(request, body.get("project_id"))
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required; use 0 for the first selection")
fingerprint = _fingerprint(body)
mode = _contract(body.get("mode"), body.get("advanced") is True, body.get("override_route_id"))
record_id = _record_id(conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
request.audit("modes.select", replayed["id"], reason="idempotent_replay")
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
exists = request.store.exists(FAMILY, record_id)
if expected != (request.store.get(FAMILY, record_id)["revision"] if exists else 0):
raise Conflict("mode selection revision does not match If-Match")
stamp = now_iso()
record = {
"id": record_id,
"schema": "hux.mode_selection.v1",
"owner": request.identity.subject,
"project_id": body["project_id"],
"conversation_id": conversation["id"],
"mode": mode,
"updated_at": stamp,
}
stored = request.store.put(FAMILY, record, expected_revision=expected)
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "id": record_id, "at": stamp})
with request.store.lock("conversations"):
current = request.store.get("conversations", conversation["id"])
request.store.put("conversations", {**current, "mode": mode["mode"], "updated_at": stamp}, current["revision"])
request.audit("modes.select", record_id)
return Response(200, stored, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
def register(router: Router) -> None:
"""Attach HUX-06 routes."""
router.add("GET", "/hux/v1/modes", CARD, "modes.list", list_modes)
router.add("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.read", get_selection)
router.add("PUT", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.select", put_selection)

View File

@ -0,0 +1,276 @@
"""HUX-07 scoped multimodal metadata, lineage and transcript corrections.
This service never accepts media bytes and has no capture endpoint. It only
records metadata after an approved autonomy action, plus inert camera/screen
intents that a human-facing client may send through the HUX-05 approval lane.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from hux import contracts, redaction
from hux.errors import Conflict, Forbidden, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.store import check_id, new_id, now_iso
CARD = "HUX-07"
ITEMS = "multimodal_items"
CORRECTIONS = "transcript_corrections"
INTENTS = "capture_intents"
MAX_ITEMS = 2000
MAX_CORRECTIONS = 10000
MAX_INTENTS = 1000
EXECUTABLE_MIMES = frozenset({"text/html", "application/xhtml+xml", "image/svg+xml", "application/xml", "text/xml"})
SAFE_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".wav", ".webm", ".ogg", ".mp4", ".pdf", ".txt")
KIND_MIMES = {
"image": frozenset({"image/jpeg", "image/png", "image/webp"}),
"audio": frozenset({"audio/wav", "audio/webm", "audio/ogg"}),
"video": frozenset({"video/webm", "video/mp4"}),
"document": frozenset({"application/pdf", "text/plain"}),
}
ITEM_FIELDS = frozenset({"project_id", "kind", "source", "filename", "mime", "bytes", "hash", "approval_id", "lineage"})
SCHEMAS = contracts.load_all()
SCHEMAS["multimodal.schema.json"] = contracts.load_schema("multimodal.schema.json")
def _body(request: Request, allowed: frozenset[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected multimodal fields", sorted(extra))
return request.body
def _scope(request: Request, project_id: Any) -> dict[str, Any]:
from hux import organization
try:
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
path_project = request.params.get("project_id")
actual = conversation.get("project_id")
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
raise NotFound("project or conversation not found")
if not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return conversation
def _if_match(request: Request, current: int) -> None:
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
if expected != current:
raise Conflict("revision does not match If-Match")
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _digest(body: dict[str, Any]) -> str:
return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, family: str, key: str, digest: str) -> dict[str, Any] | None:
for row in request.store.read(family, "idempotency"):
if row.get("key") != key:
continue
if row.get("digest") != digest:
raise Conflict("Idempotency-Key was already used with different metadata")
return request.store.get(family, row["id"])
return None
def _remember(request: Request, family: str, key: str, digest: str, record_id: str) -> None:
request.store.append(family, "idempotency", {"key": key, "digest": digest, "id": record_id, "at": now_iso()})
def _validate(record: dict[str, Any], pointer: str) -> None:
problems = contracts.validate("multimodal.schema.json", record, SCHEMAS, pointer)
if problems:
raise Invalid("multimodal record failed contract validation", problems)
def _approval(request: Request, approval_id: Any, source: Any) -> str:
from hux import policy
try:
approval = policy.load_approval(request.store, check_id(approval_id))
except (Invalid, NotFound) as error:
raise Forbidden("approved autonomy action is required") from error
capability = "external_side_effect" if source in {"camera", "screen"} else "artifact_write"
if approval.get("status") != "approved" or approval.get("conversation_id") != request.params["id"]:
raise Forbidden("approved autonomy action is required")
if approval.get("capability") != capability:
raise Forbidden("approval does not cover this multimodal action")
return approval["id"]
def _lineage(request: Request, body: Any, project_id: str) -> dict[str, Any] | None:
if body is None:
return None
if not isinstance(body, dict) or set(body) - {"parent_item_id", "artifact_id", "artifact_version"}:
raise Invalid("lineage must contain only a parent item or artifact version")
if "parent_item_id" in body:
if len(body) != 1:
raise Invalid("parent-item lineage cannot also name an artifact")
parent = request.store.get(ITEMS, check_id(body["parent_item_id"]))
if parent["conversation_id"] != request.params["id"] or parent["project_id"] != project_id:
raise NotFound("lineage item not found")
return {"parent_item_id": parent["id"]}
if set(body) != {"artifact_id", "artifact_version"} or not isinstance(body["artifact_version"], int):
raise Invalid("artifact lineage needs artifact_id and artifact_version")
from hux import artifacts
artifact = request.store.get(artifacts.FAMILY, check_id(body["artifact_id"]))
if artifact.get("project_id") != project_id or artifact.get("conversation_id") != request.params["id"]:
raise NotFound("lineage artifact not found")
version = next((row for row in artifact["versions"] if row["version"] == body["artifact_version"]), None)
if version is None:
raise NotFound("lineage artifact version not found")
if version["content_ref"]["mime"].lower() in EXECUTABLE_MIMES or artifact.get("type") in {"html", "svg"}:
raise Invalid("executable HTML and SVG lineage is not accepted")
return {"artifact_id": artifact["id"], "artifact_version": version["version"]}
def create_item(request: Request) -> Response:
"""Register metadata for approved media; bytes are never accepted here."""
body = _body(request, ITEM_FIELDS)
conversation = _scope(request, body.get("project_id"))
key, digest = _key(request), _digest(body)
with request.store.lock(ITEMS):
replayed = _replay(request, ITEMS, key, digest)
if replayed is not None:
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
_if_match(request, 0)
if request.store.count(ITEMS) >= MAX_ITEMS:
raise TooLarge("multimodal item limit reached")
filename, mime = body.get("filename"), body.get("mime")
if not isinstance(filename, str) or not filename.lower().endswith(SAFE_SUFFIXES):
raise Invalid("filename is missing or executable")
if not isinstance(mime, str) or mime.lower() in EXECUTABLE_MIMES:
raise Invalid("executable HTML, XML and SVG are not accepted")
if mime.lower() not in KIND_MIMES.get(body.get("kind"), frozenset()):
raise Invalid("kind and MIME type do not agree")
approval_id = _approval(request, body.get("approval_id"), body.get("source"))
lineage = _lineage(request, body.get("lineage"), body["project_id"])
record = {
"schema": "hux.multimodal_item.v1", "id": new_id("mmi"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": conversation["id"], "kind": body.get("kind"),
"source": body.get("source"), "filename": filename, "mime": mime.lower(), "bytes": body.get("bytes"),
"hash": body.get("hash"), "approval_id": approval_id, "status": "metadata_only", "created_at": now_iso(),
}
if lineage:
record["lineage"] = lineage
_validate({**record, "revision": 1}, "/$defs/item")
stored = request.store.put(ITEMS, record, expected_revision=0)
_remember(request, ITEMS, key, digest, stored["id"])
request.audit("multimodal.create", stored["id"])
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
def list_items(request: Request) -> Response:
"""List metadata in one project/conversation scope."""
project_id = request.params["project_id"]
_scope(request, project_id)
items = [row for row in request.store.scan(ITEMS) if row["project_id"] == project_id and row["conversation_id"] == request.params["id"]]
request.audit("multimodal.list", request.params["id"])
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def get_item(request: Request) -> Response:
"""Read one metadata record only within its path scope."""
project_id = request.params["project_id"]
_scope(request, project_id)
try:
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
except (Invalid, NotFound) as error:
raise NotFound("multimodal item not found") from error
if item["project_id"] != project_id or item["conversation_id"] != request.params["id"]:
raise NotFound("multimodal item not found")
request.audit("multimodal.read", item["id"])
return Response(200, item, {"ETag": str(item["revision"]), "Cache-Control": "no-store"})
def correct_transcript(request: Request) -> Response:
"""Append an immutable transcript correction and point the media head to it."""
body = _body(request, frozenset({"project_id", "replacement_text"}))
_scope(request, body.get("project_id"))
key, digest = _key(request), _digest({**body, "item_id": request.params["item_id"]})
with request.store.lock(CORRECTIONS):
replayed = _replay(request, CORRECTIONS, key, digest)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
if item["project_id"] != body["project_id"] or item["conversation_id"] != request.params["id"]:
raise NotFound("multimodal item not found")
if item["kind"] not in {"audio", "video"}:
raise Invalid("only audio or video transcripts can be corrected")
_if_match(request, item["revision"])
text = body.get("replacement_text")
if not isinstance(text, str) or not text.strip() or len(text) > 10000:
raise Invalid("replacement_text must contain 1 to 10000 characters")
if request.store.count(CORRECTIONS) >= MAX_CORRECTIONS:
raise TooLarge("transcript correction limit reached")
record = {
"schema": "hux.transcript_correction.v1", "id": new_id("trc"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": request.params["id"], "item_id": item["id"],
"replacement_text": text, "created_at": now_iso(),
}
_validate({**record, "revision": 1}, "/$defs/correction")
stored = request.store.put(CORRECTIONS, record, expected_revision=0)
with request.store.lock(ITEMS):
updated = request.store.put(ITEMS, {**item, "latest_correction_id": stored["id"]}, item["revision"])
_remember(request, CORRECTIONS, key, digest, stored["id"])
request.audit("multimodal.correct", stored["id"])
return Response(201, stored, {"ETag": str(updated["revision"]), "Cache-Control": "no-store"})
def create_capture_intent(request: Request) -> Response:
"""Record an inert camera/screen proposal; it never grants execution."""
body = _body(request, frozenset({"project_id", "source", "purpose"}))
_scope(request, body.get("project_id"))
key, digest = _key(request), _digest(body)
with request.store.lock(INTENTS):
replayed = _replay(request, INTENTS, key, digest)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
_if_match(request, 0)
if request.store.count(INTENTS) >= MAX_INTENTS:
raise TooLarge("capture intent limit reached")
purpose = body.get("purpose")
if body.get("source") not in {"camera", "screen"} or not isinstance(purpose, str) or not purpose.strip():
raise Invalid("source and purpose are required")
purpose = redaction.scrub_text(purpose[:280])[0]
record = {
"schema": "hux.capture_intent.v1", "id": new_id("cap"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": request.params["id"], "source": body["source"],
"purpose": purpose, "status": "proposed", "requires_approval": True, "execution_allowed": False,
"created_at": now_iso(),
}
_validate({**record, "revision": 1}, "/$defs/capture_intent")
stored = request.store.put(INTENTS, record, expected_revision=0)
_remember(request, INTENTS, key, digest, stored["id"])
request.audit("multimodal.intent", stored["id"])
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
def register(router: Router) -> None:
"""Attach metadata-only HUX-07 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}"
router.add("POST", base + "/multimodal/items", CARD, "multimodal.create", create_item)
router.add("GET", base + "/multimodal/items", CARD, "multimodal.list", list_items)
router.add("GET", base + "/multimodal/items/{item_id}", CARD, "multimodal.read", get_item)
router.add("POST", base + "/multimodal/items/{item_id}/transcript-corrections", CARD, "multimodal.correct", correct_transcript)
router.add("POST", base + "/capture-intents", CARD, "multimodal.intent", create_capture_intent)

View File

@ -0,0 +1,384 @@
"""HUX-03 organisation: projects, conversations, branch lineage and search.
Projects own conversations; conversations carry tags, pins, a mode and an
optional branch pointer to the conversation they forked from. Search covers
only the ``project.schema.json#/$defs/search_index`` fields this increment
indexes (title, tags, project_name, and artifact titles when the artifacts
lane is present); message text is not indexed here and the response says so.
"""
from __future__ import annotations
import re
from typing import Any
from hux import contracts, redaction
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import TenantStore, check_id, new_id, now_iso
CARD = "HUX-03"
PROJECTS = "projects"
CONVERSATIONS = "conversations"
MAX_PROJECTS = 200
MAX_CONVERSATIONS = 2000
PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_mode")
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
INDEXED = ("title", "tags", "project_name", "artifact_titles")
NOT_INDEXED = ("message_text",)
MESSAGE_KINDS = ("message.user", "message.assistant")
MESSAGE_SCAN_CONVERSATIONS = 100
MESSAGE_SCAN_EVENTS = 300
SEARCH_PAGE = 50
SCHEMAS = contracts.load_all()
TOKEN_RE = re.compile(r"[a-z0-9]+")
def project_exists(store: TenantStore, project_id: str) -> bool:
"""True when this tenant owns a project with that id (helper for other lanes)."""
return isinstance(project_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", project_id)) and store.exists(PROJECTS, project_id)
def conversation_exists(store: TenantStore, conversation_id: str) -> bool:
"""True when this tenant owns a conversation with that id (helper for other lanes)."""
return isinstance(conversation_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", conversation_id)) and store.exists(CONVERSATIONS, conversation_id)
def project_of(store: TenantStore, conversation_id: str | None) -> str | None:
"""The project a conversation belongs to, or None when unknown or unfiled."""
if not conversation_id or not conversation_exists(store, conversation_id):
return None
return store.get(CONVERSATIONS, conversation_id).get("project_id")
def checked(record: dict[str, Any]) -> dict[str, Any]:
"""Raise Invalid unless ``record`` satisfies its contract."""
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("record fails contract", problems)
return record
def body_dict(request: Request) -> dict[str, Any]:
"""The JSON object body or Invalid."""
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Only the client-settable fields, secret-scrubbed (F9); ids, owner, timestamps and revision are server-set."""
return redaction.scrub_value({k: body[k] for k in fields if k in body}, [])
def replay(store: TenantStore, family: str, key: str) -> dict[str, Any] | None:
"""The record an Idempotency-Key already created in this family, if any."""
for row in store.read(family, "idempotency"):
if row["key"] == key:
return store.get(family, row["id"])
return None
def create(request: Request, family: str, record: dict[str, Any], key: str, cap: int) -> tuple[dict[str, Any], int]:
"""Create under the family lock honouring Idempotency-Key and the family count cap."""
with request.store.lock(family):
if key:
existing = replay(request.store, family, key)
if existing is not None:
return existing, 200
if request.store.count(family) >= cap:
raise Conflict(f"{family} cap of {cap} reached")
stored = request.store.put(family, checked({**record, "revision": 1}))
if key:
request.store.append(family, "idempotency", {"key": key, "id": stored["id"]})
return stored, 201
def update(request: Request, family: str, fields: tuple[str, ...]) -> dict[str, Any]:
"""PATCH under If-Match; a missing If-Match is accepted but audited as unconditional (SO-44)."""
changes = pick(body_dict(request), fields)
expected = request.if_match()
with request.store.lock(family):
current = request.store.get(family, check_id(request.params["id"]))
if "project_id" in changes and changes["project_id"] is not None and not project_exists(request.store, changes["project_id"]):
raise NotFound("project not found")
record = {**current, **{k: v for k, v in changes.items() if v is not None}, "updated_at": now_iso()}
if changes.get("project_id", "") is None:
record.pop("project_id", None)
stored = request.store.put(family, checked(record), expected_revision=expected)
request.audit(f"{family}.update", stored["id"], reason="" if expected is not None else "unconditional_write")
return stored
def etag(record: dict[str, Any]) -> dict[str, str]:
"""Revision as ETag so clients can send it back in If-Match."""
return {"ETag": str(record["revision"])}
# -- projects ------------------------------------------------------------------
def create_project(request: Request) -> Response:
"""``POST /hux/v1/projects``."""
body = pick(body_dict(request), PROJECT_FIELDS)
stamp = now_iso()
record = {
"schema": "hux.project.v1", "id": new_id("prj"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **body, "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, PROJECTS, record, request.idempotency_key(), MAX_PROJECTS)
request.audit("projects.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def list_projects(request: Request) -> Response:
"""``GET /hux/v1/projects?archived=``: pinned first, then most recently updated."""
archived = request.query.get("archived")
items = [p for p in request.store.scan(PROJECTS) if archived is None or p["archived"] == (archived == "true")]
items.sort(key=lambda p: p["updated_at"], reverse=True)
items.sort(key=lambda p: not p["pinned"])
request.audit("projects.list", "projects")
return page(items)
def get_project(request: Request) -> Response:
"""``GET /hux/v1/projects/{id}``."""
record = request.store.get(PROJECTS, check_id(request.params["id"]))
request.audit("projects.read", record["id"])
return Response(200, record, etag(record))
def patch_project(request: Request) -> Response:
"""``PATCH /hux/v1/projects/{id}`` with If-Match."""
stored = update(request, PROJECTS, PROJECT_FIELDS)
return Response(200, stored, etag(stored))
# -- conversations -------------------------------------------------------------
def new_conversation(request: Request, body: dict[str, Any], extra: dict[str, Any] | None = None) -> Response:
"""Build, validate and store a conversation from client fields plus server-set extras."""
if body.get("project_id") is not None and not project_exists(request.store, body["project_id"]):
raise NotFound("project not found")
stamp = now_iso()
record = {
"schema": "hux.conversation.v1", "id": new_id("conv"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **{k: v for k, v in body.items() if v is not None},
**(extra or {}), "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, CONVERSATIONS, record, request.idempotency_key(), MAX_CONVERSATIONS)
request.audit("conversations.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def create_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations``."""
return new_conversation(request, pick(body_dict(request), CONVERSATION_FIELDS))
def list_conversations(request: Request) -> Response:
"""``GET /hux/v1/conversations?project_id=&tag=&pinned=&archived=``: newest activity first."""
q = request.query
flags = {k: q[k] == "true" for k in ("pinned", "archived") if k in q}
items = []
for index, record in enumerate(request.store.scan(CONVERSATIONS)):
if "project_id" in q and record.get("project_id") != q["project_id"]:
continue
if "tag" in q and q["tag"] not in record["tags"]:
continue
if any(record[k] != v for k, v in flags.items()):
continue
items.append((record.get("last_message_at", record["updated_at"]), index, record))
items.sort(key=lambda item: item[:2], reverse=True)
request.audit("conversations.list", "conversations")
return page([record for _, _, record in items])
def get_conversation(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}``."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
request.audit("conversations.read", record["id"])
return Response(200, record, etag(record))
def patch_conversation(request: Request) -> Response:
"""``PATCH /hux/v1/conversations/{id}`` with If-Match."""
stored = update(request, CONVERSATIONS, CONVERSATION_FIELDS)
return Response(200, stored, etag(stored))
def branch_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/branch``: fork at a message, keeping project, tags and mode."""
body = body_dict(request)
point = body.get("branch_point_message_id")
if not isinstance(point, str) or not 1 <= len(point) <= 120:
raise Invalid("branch_point_message_id required")
parent = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
fields = {"title": body.get("title") or f"{parent['title']} (branch)"[:200], "tags": list(parent["tags"]),
"project_id": parent.get("project_id"), "mode": parent.get("mode")}
return new_conversation(request, fields, {"branch": {"parent_conversation_id": parent["id"], "branch_point_message_id": point}})
def lineage(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/lineage``: ancestors root-first plus direct children."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
ancestors: list[dict[str, Any]] = []
cursor, seen = record, {record["id"]}
while "branch" in cursor and len(ancestors) < 64:
parent_id = cursor["branch"]["parent_conversation_id"]
if parent_id in seen or not request.store.exists(CONVERSATIONS, parent_id):
break
cursor = request.store.get(CONVERSATIONS, parent_id)
seen.add(parent_id)
ancestors.insert(0, cursor)
children = [c for c in request.store.scan(CONVERSATIONS) if c.get("branch", {}).get("parent_conversation_id") == record["id"]]
request.audit("conversations.lineage", record["id"])
return Response(200, {"conversation": record, "ancestors": ancestors, "children": children})
# -- search --------------------------------------------------------------------
def artifact_index(store: TenantStore) -> dict[str, list[str]]:
"""Conversation id -> titles of the artifacts filed under it (F13c): both the conversation's ``artifact_ids`` and artifacts that name the conversation."""
try:
from hux import artifacts
except ModuleNotFoundError:
return {}
index: dict[str, list[str]] = {}
for artifact in store.scan(artifacts.FAMILY):
if isinstance(artifact.get("conversation_id"), str) and isinstance(artifact.get("title"), str):
index.setdefault(artifact["conversation_id"], []).append(artifact["title"])
for conversation in store.scan(CONVERSATIONS):
ids = [i for i in conversation.get("artifact_ids", []) if isinstance(i, str)]
titles = artifacts.artifact_titles(store, ids).values() if ids else []
for title in titles:
index.setdefault(conversation["id"], []).append(title)
return index
def artifact_titles(store: TenantStore, conversation: dict[str, Any], index: dict[str, list[str]] | None = None) -> list[str]:
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
index = artifact_index(store) if index is None else index
return list(dict.fromkeys(index.get(conversation["id"], [])))
def mark_forgotten(store: TenantStore, conversation_id: str) -> bool:
"""Blank a forgotten conversation's title and tags and archive it (F9); False when there is no document."""
with store.lock(CONVERSATIONS):
if not conversation_exists(store, conversation_id):
return False
current = store.get(CONVERSATIONS, conversation_id)
record = {**current, "title": "[forgotten]", "tags": [], "archived": True, "updated_at": now_iso()}
store.put(CONVERSATIONS, checked(record), expected_revision=current["revision"])
return True
def tokens(text: str) -> list[str]:
"""Lowercased alphanumeric terms."""
return TOKEN_RE.findall(text.lower())
def score(record: dict[str, Any], terms: list[str], project_name: str, titles: list[str]) -> int:
"""Title hit beats tag hit beats project/artifact hit; every term must match somewhere."""
fields = {"title": tokens(record["title"]), "tags": [t for tag in record["tags"] for t in tokens(tag)],
"project_name": tokens(project_name), "artifact_titles": [t for title in titles for t in tokens(title)]}
weight = {"title": 4, "tags": 3, "project_name": 2, "artifact_titles": 1}
total = 0
for term in terms:
hit = sum(weight[f] * words.count(term) for f, words in fields.items())
if not hit:
return 0
total += hit
return total
def message_text_score(store: TenantStore, conversation: dict[str, Any], terms: list[str]) -> int:
"""Bounded match over this conversation's stored message events.
Privacy wins: forgotten conversations were already excluded, private-mode
conversations are never scanned, and restricted or fully redacted events
stay invisible to search exactly as they are on the timeline.
"""
from hux import events
if events.is_private(store, conversation["id"]):
return 0
rows = store.read(events.FAMILY, conversation["id"])[-MESSAGE_SCAN_EVENTS:]
total = 0
for row in rows:
if row.get("kind") not in MESSAGE_KINDS or row.get("sensitivity") == "restricted":
continue
if (row.get("redaction") or {}).get("level") == "full":
continue
words = tokens(str(row.get("summary", "")))
total += sum(words.count(term) for term in terms)
return total
def search(request: Request) -> Response:
"""``GET /hux/v1/search?q=&project_id=&include=&cursor=``: best match first.
The default scope is the indexed fields only. ``include=message_text``
additionally scans the stored message events of the most recently active
``MESSAGE_SCAN_CONVERSATIONS`` candidates (bounded, privacy-enforced) and
pages deterministically: rank order is (score desc, updated_at desc, id),
the cursor is the offset into that total order.
"""
terms = tokens(request.query.get("q", "")[:200])
if not terms:
raise Invalid("q is required")
include = request.query.get("include")
if include not in (None, "message_text"):
raise Invalid("include supports only message_text")
cursor = request.query.get("cursor", "0")
if not cursor.isdigit():
raise Invalid("cursor must be a non-negative integer")
project_filter = request.query.get("project_id")
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
index = artifact_index(request.store)
candidates = [
record for record in request.store.scan(CONVERSATIONS)
if not project_filter or record.get("project_id") == project_filter
]
scanned = set()
if include:
recent = sorted(candidates, key=lambda r: (r["updated_at"], r["id"]), reverse=True)
scanned = {record["id"] for record in recent[:MESSAGE_SCAN_CONVERSATIONS]}
ranked = []
for record in candidates:
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
if record["id"] in scanned and not record.get("archived"):
points += message_text_score(request.store, record, terms)
if points:
ranked.append((points, record))
if include:
# Paginated mode needs one total order; ids break updated_at ties.
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"], pair[1]["id"]))
else:
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
request.audit("search.query", "conversations")
if not include:
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
start = int(cursor)
window = ranked[start : start + SEARCH_PAGE]
return Response(200, {
"items": [r for _, r in window], "scores": {r["id"]: s for s, r in window},
"next": str(start + SEARCH_PAGE) if len(ranked) > start + SEARCH_PAGE else None,
"indexed": list(INDEXED) + ["message_text"], "not_indexed": [],
"message_scan_limit": MESSAGE_SCAN_CONVERSATIONS,
})
def register(router: Router) -> None:
"""Attach HUX-03 routes."""
router.add("POST", "/hux/v1/projects", CARD, "projects.create", create_project)
router.add("GET", "/hux/v1/projects", CARD, "projects.list", list_projects)
router.add("GET", "/hux/v1/projects/{id}", CARD, "projects.read", get_project)
router.add("PATCH", "/hux/v1/projects/{id}", CARD, "projects.update", patch_project)
router.add("POST", "/hux/v1/conversations", CARD, "conversations.create", create_conversation)
router.add("GET", "/hux/v1/conversations", CARD, "conversations.list", list_conversations)
router.add("GET", "/hux/v1/conversations/{id}", CARD, "conversations.read", get_conversation)
router.add("PATCH", "/hux/v1/conversations/{id}", CARD, "conversations.update", patch_conversation)
router.add("POST", "/hux/v1/conversations/{id}/branch", CARD, "conversations.branch", branch_conversation)
router.add("GET", "/hux/v1/conversations/{id}/lineage", CARD, "conversations.lineage", lineage)
router.add("GET", "/hux/v1/search", CARD, "search.query", search)

View File

@ -0,0 +1,428 @@
"""HUX-05 autonomy: policy documents per scope and the approval queue.
A policy fixes the autonomy level, explicit grants and budgets for a scope
(global, project or conversation). ``rules.effective_decision`` is the only
resolver: an approval request resolves to allow, ask or deny against the most
specific policy, and every external side effect asks regardless. Budgets,
the pre-side-effect gate and cancellation receipts live in ``hux.budgets``;
this module registers their routes so the family stays one card.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, rules
from hux.errors import BudgetExhausted, Conflict, Forbidden, Invalid
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id
CARD = "HUX-05"
FAMILY = "policy"
APPROVALS = "approvals"
SCOPES = ("global", "project", "conversation")
APPROVAL_TTL = timedelta(hours=24)
ALWAYS_TTL = timedelta(days=30)
SESSION_TTL = timedelta(hours=24)
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
HUMAN_TRUSTS = frozenset({"router", "relay"})
DEFAULT_BUDGETS = {
"tokens_per_run": 200000, "tool_calls_per_run": 40, "wall_clock_seconds": 900,
"delegations_per_run": 4, "spend_units": 50, "subagents_per_run": 2,
}
SCHEMAS = contracts.load_all()
def clock() -> datetime:
"""Current UTC time; tests replace this to move approvals past expiry."""
return datetime.now(timezone.utc)
def now() -> datetime:
"""Indirection so monkeypatching ``clock`` reaches every module."""
return clock()
def iso(when: datetime) -> str:
"""RFC 3339 second-precision UTC string."""
return when.strftime("%Y-%m-%dT%H:%M:%SZ")
def parse(stamp: str) -> datetime:
"""Inverse of ``iso``."""
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def run_key(run_id: str) -> str:
"""Stable hex handle for a free-form run id so it can name a document."""
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
def is_human(identity: Identity) -> bool:
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
def require_human(identity: Identity, what: str) -> None:
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
if not is_human(identity):
raise Forbidden(f"{what} only from a human surface")
def require_worker(identity: Identity, what: str) -> None:
"""Forbidden unless the separately keyed Worker gateway is making the call."""
if identity.trust != "worker" or identity.surface != "worker":
raise Forbidden(f"{what} only from worker trust")
def actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if is_human(identity):
return {"type": "user", "id": identity.subject}
return {"type": "system", "id": identity.surface}
def provenance(identity: Identity) -> dict[str, Any]:
"""Server-set provenance; nothing here comes from the body."""
return {"surface": identity.surface, "actor": actor_for(identity), "recorded_at": iso(now())}
def public(record: dict[str, Any], revisioned: bool = False) -> dict[str, Any]:
"""Strip store-internal fields before a record leaves the service."""
return {k: v for k, v in record.items() if not k.startswith("_") and (revisioned or k != "revision")}
def emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, **extra: Any) -> None:
"""Record an activity event through the events lane when it is present."""
if not conversation_id:
return
try:
from hux import events
except ModuleNotFoundError:
return
events.emit(store, identity, conversation_id, kind, summary, **extra)
def checked(record: dict[str, Any]) -> dict[str, Any]:
"""Raise Invalid unless ``record`` satisfies its contract; internal fields are ignored."""
if record["schema"] == "hux.policy.v1":
candidate = public({"revision": 1, **record}, revisioned=True)
else:
candidate = public(record)
problems = contracts.validate_record(candidate, SCHEMAS)
if problems:
raise Invalid("record fails contract", problems)
return record
def body_dict(request: Request) -> dict[str, Any]:
"""The JSON object body or Invalid."""
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
# -- policy documents --------------------------------------------------------
def policy_id(level: str, scope_id: str | None) -> str:
"""Document id for a scope; global has exactly one."""
if level not in SCOPES:
raise Invalid("scope must be global, project or conversation")
if level == "global":
return "pol_global"
if scope_id is None or len(scope_id) > 60:
raise Invalid("scope_id required and at most 60 characters for project and conversation scopes")
return f"pol_{level}.{check_id(scope_id)}"
def default_policy(identity: Identity) -> dict[str, Any]:
"""The ``safe`` policy every tenant starts with."""
return {
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
"provenance": provenance(identity), "updated_at": iso(now()), "_budget_epoch": new_id("bep"),
}
def global_policy(store: TenantStore, identity: Identity) -> dict[str, Any]:
"""Read the global policy, creating the default on first touch."""
with store.lock(FAMILY):
if store.exists(FAMILY, "pol_global"):
return store.get(FAMILY, "pol_global")
return store.put(FAMILY, checked({**default_policy(identity), "revision": 1}))
def effective_policy(store: TenantStore, identity: Identity, level: str, scope_id: str | None) -> dict[str, Any]:
"""Most specific policy along conversation -> project -> global."""
chain: list[tuple[str, str | None]] = [(level, scope_id)]
if level == "conversation":
from hux import organization # lazy: organization never imports policy, but keep import order free
parent = organization.project_of(store, scope_id)
if parent:
chain.append(("project", parent))
for lvl, sid in chain:
if lvl != "global" and store.exists(FAMILY, policy_id(lvl, sid)):
return store.get(FAMILY, policy_id(lvl, sid))
return global_policy(store, identity)
def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
"""Validate grants and pin every one to a server-set expiry of at most 30 days (SO-38)."""
if not isinstance(grants, list):
raise Invalid("grants must be a list")
ceiling = now() + ALWAYS_TTL
out = []
for grant in grants:
if not isinstance(grant, dict) or grant.get("capability") not in rules.CAPABILITIES:
raise Invalid("grant needs a known capability")
if grant.get("decision") not in ("allow", "ask", "deny"):
raise Invalid("grant decision must be allow, ask or deny")
if grant["decision"] == "allow":
require_human(identity, "allow grants")
expires = grant.get("expires_at")
if not isinstance(expires, str) or parse(expires) > ceiling:
expires = iso(ceiling)
out.append({"capability": grant["capability"], "decision": grant["decision"], "expires_at": expires, "granted_by": actor_for(identity)})
return out
def get_policy(request: Request) -> Response:
"""``GET /hux/v1/policy?scope=&scope_id=``: the effective policy for a scope."""
level = request.query.get("scope", "global")
scope_id = request.query.get("scope_id")
policy_id(level, scope_id)
record = effective_policy(request.store, request.identity, level, scope_id)
request.audit("policy.read", record["id"])
return Response(200, public(record, revisioned=True), {"ETag": str(record["revision"])})
def put_policy(request: Request) -> Response:
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body; humans only (F1)."""
require_human(request.identity, "policy writes")
body = body_dict(request)
scope = body.get("scope") or {}
if not isinstance(scope, dict):
raise Invalid("scope must be an object")
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
raise Invalid("autonomy must be ask_first, safe or autonomous")
if body["autonomy"] == "autonomous":
require_human(request.identity, "autonomy escalation")
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
record = {
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
"_budget_epoch": new_id("bep"),
}
expected = request.if_match()
with request.store.lock(FAMILY):
exists = request.store.exists(FAMILY, record_id)
stored = request.store.put(FAMILY, checked(record), expected_revision=expected)
reason = "unconditional_write" if exists and expected is None else ""
request.audit("policy.write", record_id, reason=reason)
return Response(200, public(stored, revisioned=True), {"ETag": str(stored["revision"])})
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
"""Append an allow grant to a scope's policy after a session/always decision; the decider must be human."""
require_human(identity, "allow grants")
record_id = policy_id(level, scope_id)
with store.lock(FAMILY):
if store.exists(FAMILY, record_id):
record = store.get(FAMILY, record_id)
else:
scope = {"level": level, **({"scope_id": scope_id} if scope_id else {})}
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
record.pop("revision")
expected = record.pop("revision", None)
budget_epoch = record.get("_budget_epoch") or f"{record['id']}:{expected or 1}"
grants = [g for g in record["grants"] if g["capability"] != capability]
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
record = {
**record, "grants": grants[-64:], "provenance": provenance(identity),
"updated_at": iso(now()), "_budget_epoch": budget_epoch,
}
store.put(FAMILY, checked(record), expected_revision=expected)
# -- approvals -----------------------------------------------------------------
def refresh(store: TenantStore, record: dict[str, Any]) -> dict[str, Any]:
"""Expire a pending approval whose window has closed (SO-42); terminal records never change."""
if record["status"] == "pending" and parse(record["expires_at"]) <= now():
with store.lock(APPROVALS):
record = store.put(APPROVALS, {**record, "status": "expired"})
return record
def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
"""Read one approval for this tenant; unknown ids are 404 whoever owns them."""
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
def request_fingerprint(body: dict[str, Any]) -> str:
"""Canonical digest that binds an Idempotency-Key to exactly one approval request."""
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
return "sha256:" + hashlib.sha256(canonical).hexdigest()
def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, rejecting a changed request body."""
for row in store.read(APPROVALS, "idempotency"):
if row["key"] == key:
record = load_approval(store, row["id"])
if record.get("_request_fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different approval request")
return record
return None
def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> str:
"""Effective decision for a request; external side effects never auto-allow (SO-39)."""
decision = rules.effective_decision(policy, capability, now())
if external and decision == "allow":
return "ask"
return decision
PRIVATE_DENIED = frozenset({"network", "web_search", "send_message", "shell", "delegate"})
def private_mode_denies(store: TenantStore, conversation_id: str | None, capability: str) -> bool:
"""Private conversations never release web, shell, messaging or delegation.
The mode catalog says so (``rules.MODE_CATALOG['private']['tools']``);
memory writes are already refused by the privacy state. The conversation
record's ``mode`` field is written only by the HUX-06 selection route.
"""
if capability not in PRIVATE_DENIED or not conversation_id:
return False
try:
record = store.get("conversations", conversation_id)
except Exception:
return False
return record.get("mode") == "private"
def create_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
require_worker(request.identity, "approval requests")
body = body_dict(request)
key = request.idempotency_key()
conversation_id = check_id(body.get("conversation_id"))
capability = body.get("capability")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
from hux import budgets # lazy: budgets imports this module
run_id = budgets.checked_run_id(body.get("run_id"))
bound_conversation = budgets.run_conversation(request.store, run_id)
if bound_conversation != conversation_id:
raise Invalid("run is not authoritatively bound to this conversation")
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") != 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
fingerprint = request_fingerprint(body)
if key:
with request.store.lock(APPROVALS):
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
state = budgets.budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]:
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
raise BudgetExhausted("run budget exhausted", state["exhausted"])
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
decision = resolve_request(policy, capability, external)
if private_mode_denies(request.store, conversation_id, capability):
decision = "deny"
stamp = now()
record: dict[str, Any] = {
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id,
"capability": capability, "request": {**req, "external": external},
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
}
if decision != "ask":
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
if key:
record["idempotency_key"] = key
record["_request_fingerprint"] = fingerprint
with request.store.lock(APPROVALS):
if key:
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
stored = request.store.put(APPROVALS, checked(record))
if key:
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
request.audit("approvals.create", stored["id"], reason=f"policy_{decision}")
kind = "approval.requested" if decision == "ask" else "approval.resolved"
emit(request.store, request.identity, conversation_id, kind, f"{capability}: {record['request'].get('summary', '')}"[:280],
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
return Response(201, public(stored))
def list_approvals(request: Request) -> Response:
"""``GET /hux/v1/approvals?status=``: the queue, oldest request first."""
wanted = request.query.get("status")
if wanted and wanted not in rules.APPROVAL_TRANSITIONS:
raise Invalid("unknown status")
items = [refresh(request.store, r) for r in request.store.scan(APPROVALS)]
items = [public(r) for r in items if not wanted or r["status"] == wanted]
items.sort(key=lambda r: (r["requested_at"], r["id"]))
request.audit("approvals.list", wanted or "all")
return page(items)
def decide_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals/{id}``: a human answers once, session, always or deny (SO-35)."""
identity = request.identity
if identity.trust not in HUMAN_TRUSTS or identity.surface not in HUMAN_SURFACES:
raise Forbidden("approvals are decided only from a human surface")
choice = body_dict(request).get("choice")
if choice not in ("once", "session", "always", "deny"):
raise Invalid("choice must be once, session, always or deny")
target = "denied" if choice == "deny" else "approved"
with request.store.lock(APPROVALS):
record = load_approval(request.store, request.params["id"])
if not rules.transition_allowed(rules.APPROVAL_TRANSITIONS, record["status"], target):
raise Conflict(f"approval is {record['status']}; only pending approvals can be decided")
record = {**record, "status": target, "decision": {"choice": choice, "by": actor_for(identity), "at": iso(now())}}
stored = request.store.put(APPROVALS, checked(record))
if choice == "session":
add_grant(request.store, identity, "conversation", record["conversation_id"], record["capability"], SESSION_TTL)
elif choice == "always":
add_grant(request.store, identity, "global", None, record["capability"], ALWAYS_TTL)
request.audit("approvals.decide", stored["id"], reason=choice)
emit(request.store, identity, record["conversation_id"], "approval.resolved", f"{record['capability']} {target} ({choice})",
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
return Response(200, public(stored))
def get_approval(request: Request) -> Response:
"""``GET /hux/v1/approvals/{id}``: one record."""
record = load_approval(request.store, request.params["id"])
request.audit("approvals.read", record["id"])
return Response(200, public(record))
def register(router: Router) -> None:
"""Attach HUX-05 routes, including the budget, gate and stop routes from ``hux.budgets``."""
from hux import budgets
router.add("GET", "/hux/v1/policy", CARD, "policy.read", get_policy)
router.add("PUT", "/hux/v1/policy", CARD, "policy.write", put_policy)
router.add("POST", "/hux/v1/approvals", CARD, "approvals.create", create_approval)
router.add("GET", "/hux/v1/approvals", CARD, "approvals.list", list_approvals)
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
budgets.register_routes(router)

View File

@ -0,0 +1,299 @@
"""HUX-10 privacy behaviour: topic detection, notices, conversation forget and the retention job.
The policy itself lives in ``rules.privacy_policy``; this module records what
was shown, scopes detected topics to the conversation they appeared in, gates
memory writes for other families (``memory_write_allowed``) and runs the
daily retention audit that expires memory, decays topic context and purges
forgotten content while never touching the audit ledgers (SO-47).
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, rules
from hux.errors import Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
FAMILY = "privacy"
RETENTION_FAMILY = "retention"
TOPICS_DOC = "topics_conversations"
TOPICS_SCHEMA = "hux.conversation_topics.v1"
CONTROLS = ("forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss")
SENSITIVITY_RANK = {"public": 0, "personal": 1, "sensitive": 2, "restricted": 3}
AUDIT_STALE_HOURS = 48
SCHEMAS = contracts.load_all()
# Deliberately narrow: a false positive only asks the user before remembering,
# a false negative would store something it should not. Detection is a hint,
# never proof, so callers treat "uncertain" as sensitive (SO-21).
TOPIC_PATTERNS: dict[str, re.Pattern[str]] = {
"credentials": re.compile(r"(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|token|2fa|otp|ssh key|login credentials?)\b"),
"biometric": re.compile(r"(?i)\b(fingerprint|face ?id|retina|iris scan|voice ?print|dna|genome|biometric)\b"),
"minors": re.compile(r"(?i)\b(my (son|daughter|kid|kids|child|children|toddler|baby)|(?:\d{1,2}|[a-z]+)[- ]year[- ]old (?:son|daughter|boy|girl|child)|school ?(?:pickup|run)|daycare|minor child)\b"),
"location": re.compile(r"(?i)\b(home address|my address|lives? at \d|gps|coordinates|\d{1,5} [A-Z][a-z]+ (?:street|st|avenue|ave|road|rd|lane|ln|drive|dr)\b|latitude|longitude|track(?:ing)? (?:my|his|her) (?:phone|location))")
,
"health": re.compile(r"(?i)\b(diagnos(?:is|ed)|prescription|medication|therapist|therapy|symptoms?|doctor|clinic|hospital|surgery|chronic|depression|anxiety|cancer|diabetes|pregnan(?:t|cy)|mental health|blood (?:pressure|test))\b"),
"finance": re.compile(r"(?i)\b(salary|income|mortgage|bank account|credit card|iban|routing number|debt|loan|tax return|net worth|investment|401k|savings account|bankrupt(?:cy)?)\b"),
"legal": re.compile(r"(?i)\b(lawsuit|attorney|lawyer|court (?:date|case|order)|subpoena|arrest(?:ed)?|custody|divorce filing|criminal (?:record|charge)|restraining order|litigation)\b"),
"relationships": re.compile(r"(?i)\b(my (?:wife|husband|partner|girlfriend|boyfriend|ex)|breakup|broke up|divorce|affair|dating|marriage counsel(?:l)?ing)\b"),
}
def detect_topics(text: str) -> list[str]:
"""Sensitive topics a piece of text looks like it touches, most restrictive first. Heuristic and uncertain by design."""
found = [topic for topic, pattern in TOPIC_PATTERNS.items() if pattern.search(text or "")]
return sorted(found, key=lambda topic: (-SENSITIVITY_RANK[rules.PRIVACY_TOPICS[topic]["sensitivity"]], topic))
def topic_sensitivity(topics: list[str]) -> str:
"""Highest sensitivity among detected topics; ``personal`` when nothing matched."""
ranked = sorted((rules.PRIVACY_TOPICS[t]["sensitivity"] for t in topics), key=SENSITIVITY_RANK.get, reverse=True)
return ranked[0] if ranked else "personal"
def _parse(stamp: str) -> datetime:
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def _iso(when: datetime) -> str:
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load_topics(store: TenantStore) -> dict[str, Any]:
"""The ``conversation_topics`` document: conversation id -> scoping state."""
if store.exists(FAMILY, TOPICS_DOC):
return store.get(FAMILY, TOPICS_DOC)
return {"id": TOPICS_DOC, "schema": TOPICS_SCHEMA, "items": {}}
def _save_topics(store: TenantStore, doc: dict[str, Any]) -> None:
store.put(FAMILY, doc)
def conversation_state(store: TenantStore, conversation_id: str) -> dict[str, Any]:
"""Scoping row for one conversation (empty when nothing was ever detected)."""
return load_topics(store)["items"].get(conversation_id, {})
def mark_topic(store: TenantStore, conversation_id: str, topic: str, now: str | None = None) -> dict[str, Any]:
"""Scope ``topic`` to ``conversation_id``; the first sighting fixes ``decay_at``."""
now = now or now_iso()
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now, "memory_disabled": False, "forgotten": False})
if topic not in row["topics"]:
row["topics"].append(topic)
decay = _parse(row["first_seen"]) + timedelta(days=min(rules.PRIVACY_TOPICS[t]["decay_days"] for t in row["topics"]))
row["decay_at"] = _iso(decay)
_save_topics(store, doc)
return row
def set_flag(store: TenantStore, conversation_id: str, flag: str, value: bool) -> None:
"""Set ``memory_disabled`` or ``forgotten`` for a conversation."""
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now_iso(), "memory_disabled": False, "forgotten": False})
row[flag] = value
_save_topics(store, doc)
def memory_write_allowed(store: TenantStore, conversation_id: str | None, entry: dict[str, Any]) -> tuple[bool, str]:
"""Gate every memory write (SO-21, SO-23, SO-28). Returns (allowed, reason)."""
from hux import events
if conversation_id:
if events.is_private(store, conversation_id):
return False, "private_mode"
state = conversation_state(store, conversation_id)
if state.get("forgotten"):
return False, "conversation_forgotten"
if state.get("memory_disabled"):
return False, "memory_disabled"
if entry.get("sensitivity") == "restricted":
return False, "restricted"
topic = entry.get("topic", "general")
if topic in rules.PRIVACY_TOPICS and rules.PRIVACY_TOPICS[topic]["memory_write"] == "deny":
return False, f"topic_{topic}"
return True, "allowed"
def blocked_conversations(store: TenantStore) -> set[str]:
"""Conversations that contribute nothing to retrieval: forgotten or memory disabled (SO-23)."""
return {cid for cid, row in load_topics(store)["items"].items() if row.get("forgotten") or row.get("memory_disabled")}
def latest_audit(store: TenantStore) -> dict[str, Any] | None:
"""Newest retention audit record, or None when the job never ran."""
rows = sorted(store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"])
return rows[-1] if rows else None
def audit_stale(store: TenantStore, now: datetime | None = None) -> bool:
"""SO-27: no retention audit in the last 48 hours."""
last = latest_audit(store)
now = now or datetime.now(timezone.utc)
return last is None or now - _parse(last["ran_at"]) > timedelta(hours=AUDIT_STALE_HOURS)
# -- retention ----------------------------------------------------------------
def run_retention(store: TenantStore, now: datetime | None = None, identity: Identity | None = None) -> dict[str, Any]:
"""Expire memory, decay topic context, purge forgotten content, report; write and return ``hux.retention_audit.v1``."""
from hux import events, memory
now = now or datetime.now(timezone.utc)
expired = memory.expire_due(store, now)
decayed = 0
with store.lock(FAMILY):
doc = load_topics(store)
for conversation_id, row in doc["items"].items():
if row.get("decayed") or not row.get("topics") or _parse(row.get("decay_at", "9999-01-01T00:00:00Z")) > now:
continue
notice = rules.privacy_policy()["topics"]
text = next(t["notice"] for t in notice if t["topic"] == row["topics"][0])
decayed += events.rewrite_full(store, conversation_id, text, "topic context decayed", lambda r: r.get("sensitivity") in {"sensitive", "restricted"})
row["decayed"] = True
_save_topics(store, doc)
purged = memory.purge_forgotten(store)
record = {
"schema": "hux.retention_audit.v1",
"id": new_id("aud"),
"ran_at": _iso(now),
"policy_version": rules.privacy_policy()["version"],
"results": [
{"action": "expire_memory", "count": expired},
{"action": "decay_topic_context", "count": decayed},
{"action": "purge_forgotten_content", "count": purged},
{"action": "report", "count": 1},
],
}
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("retention audit failed contract validation", problems)
return _strip(store.put(RETENTION_FAMILY, record))
def forget_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> dict[str, Any]:
"""Mark a conversation forgotten: its memory entries move to forgotten, its events go to full redaction."""
from hux import events, memory
set_flag(store, conversation_id, "forgotten", True)
forgotten_ids = memory.forget_from_conversation(store, identity, conversation_id)
redacted = events.rewrite_full(store, conversation_id, "[forgotten conversation]", "conversation forgotten")
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted, "document_blanked": _blank_document(store, conversation_id)}
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
return {"conversation_id": conversation_id, "forgotten": True, **counts}
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
try:
from hux import organization
except ModuleNotFoundError:
return False
return organization.mark_forgotten(store, conversation_id)
# -- routes ---------------------------------------------------------------------
def _strip(record: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in record.items() if key != "revision"}
def get_policy(request: Request) -> Response:
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
request.audit("privacy.policy", "policy")
stale = audit_stale(request.store)
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
def post_notice(request: Request) -> Response:
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
from hux import events
body = request.body if isinstance(request.body, dict) else {}
topic, conversation_id = body.get("topic"), body.get("conversation_id")
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
raise Invalid("topic and conversation_id are required")
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
problems = contracts.validate_record(notice, SCHEMAS)
if problems:
raise Invalid("notice failed contract validation", problems)
request.store.append(FAMILY, "notices", notice)
mark_topic(request.store, conversation_id, topic)
chosen = body.get("chosen")
if chosen == "disable_memory_here":
set_flag(request.store, conversation_id, "memory_disabled", True)
elif chosen == "forget_this_conversation":
forget_conversation(request.store, request.identity, conversation_id)
# F13a: a notice for a conversation this subject never had must not conjure
# an event ledger for it; the notice itself is still on record.
known = events.conversation_known(request.store, conversation_id)
if known:
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
return Response(201, notice)
def post_forget(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
from hux import events
conversation_id = request.params["id"]
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
raise NotFound("conversation not found")
result = forget_conversation(request.store, request.identity, conversation_id)
request.audit("privacy.forget", conversation_id)
return Response(200, result)
def get_audit(request: Request) -> Response:
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
request.audit("privacy.audit", "retention")
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
PAGE_LIMIT = 200
def get_conversation_privacy(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
from hux import events
conversation_id = check_id(request.params["id"])
if not events.conversation_known(request.store, conversation_id):
raise NotFound("conversation not found")
state = conversation_state(request.store, conversation_id)
mode = None
try:
from hux.organization import CONVERSATIONS
mode = request.store.get(CONVERSATIONS, conversation_id).get("mode")
except (ModuleNotFoundError, ImportError, NotFound):
mode = None
request.audit("privacy.conversation_state", conversation_id)
return Response(200, {
"conversation_id": conversation_id,
"forgotten": bool(state.get("forgotten")),
"memory_disabled": bool(state.get("memory_disabled")),
"topics": sorted(state.get("topics", {})) if isinstance(state.get("topics"), dict) else list(state.get("topics", [])),
"mode": mode,
"memory_writes_allowed": not (state.get("forgotten") or state.get("memory_disabled") or mode == "private"),
})
def register(router: Router) -> None:
"""Attach HUX-10 routes."""
router.add("GET", "/hux/v1/privacy/policy", "HUX-10", "privacy.policy", get_policy)
router.add("POST", "/hux/v1/privacy/notices", "HUX-10", "privacy.notice", post_notice)
router.add("POST", "/hux/v1/conversations/{id}/forget", "HUX-10", "privacy.forget", post_forget)
router.add("GET", "/hux/v1/privacy/audit", "HUX-10", "privacy.audit", get_audit)
router.add("GET", "/hux/v1/conversations/{id}/privacy", "HUX-10", "privacy.conversation_state", get_conversation_privacy)

View File

@ -0,0 +1,235 @@
"""Redaction pipeline shared by every HUX family (SO-11..SO-14, SO-19).
Order matters and is fixed here: drop detail keys outside the per-kind
allowlist, scrub secret patterns from every remaining string, cap sizes,
then derive ``redaction.level``. Serve-time redaction (``redact_record``) is
applied again on the way out so a reader that ignores the level still never
sees more than the surface is allowed to.
"""
from __future__ import annotations
import json
import os
import re
from typing import Any
DETAIL_CAP_BYTES = 32 * 1024
LINE_CAP_BYTES = 64 * 1024
SUMMARY_MAX = 280
URI_SCHEMES = ("https://", "hux://", "artifact://")
WORKSPACE_PREFIX = "/opt/data/workspace"
HASH_KEYS = frozenset({"hash", "argument_hash"})
HUMAN_SURFACES = frozenset({"telegram", "voice"})
FULL_KEEP = frozenset({"schema", "id", "seq", "ts", "conversation_id", "kind", "provenance", "sensitivity", "redaction", "turn", "identity"})
# Each pattern is (class, regex). Classes end up in the placeholder so a
# reviewer can tell what kind of thing was removed without seeing it.
SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)")),
("bearer", re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{16,}")),
("jwt", re.compile(r"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]+)?")),
("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
("github_token", re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}")),
("gitlab_token", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
("slack_token", re.compile(r"xox[abp]-[A-Za-z0-9-]{10,}")),
("vault_token", re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
("password", re.compile(r"(?i)(?:password|passwd|pwd|secret|token)\s*[=:]\s*\S+")),
("hex", re.compile(r"\b[0-9a-fA-F]{32,}\b")),
("base64", re.compile(r"\b(?=[A-Za-z0-9+/]*[0-9])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}")),
)
# Detail keys each event kind may persist. Everything else is dropped before
# a byte reaches disk, never filtered on read only.
DETAIL_ALLOWLIST: dict[str, frozenset[str]] = {
"message.user": frozenset({"message_id", "chars", "has_attachments"}),
"message.assistant": frozenset({"message_id", "chars", "has_attachments"}),
"decision.route": frozenset({"requested", "resolved_target", "provider", "effort", "reason"}),
"decision.plan": frozenset({"steps"}),
"tool.call": frozenset({"tool", "capability", "argument_names", "argument_hash", "argument_bytes", "target_path"}),
"tool.result": frozenset({"tool", "ok", "duration_ms", "bytes", "exit_code"}),
"approval.requested": frozenset({"approval_id", "capability", "choice", "external"}),
"approval.resolved": frozenset({"approval_id", "capability", "choice", "external"}),
"side_effect.blocked": frozenset({"approval_id", "capability", "choice", "external"}),
"side_effect.released": frozenset({"approval_id", "capability", "choice", "external"}),
"memory.proposed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.committed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.forgotten": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.suppressed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.retrieval_removed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"artifact.created": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"artifact.version": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"artifact.promoted": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"run.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.cancelled": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"budget.exhausted": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"privacy.notice": frozenset({"schema", "topic", "conversation_id", "text", "controls", "shown_at"}),
"citation.attached": frozenset({"citation_id", "message_id", "passage_ids", "verdict"}),
"mode.changed": frozenset({"mode", "previous_mode", "route_id"}),
"suggestion.shown": frozenset({"suggestion_id", "action"}),
"suggestion.dismissed": frozenset({"suggestion_id", "action"}),
"release.transition": frozenset({"release_id", "state", "previous_state"}),
}
def canaries(environ: dict[str, str] | None = None) -> list[str]:
"""Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears."""
environ = os.environ if environ is None else environ
names = ("HUX_ROUTER_KEY", "HUX_RELAY_KEY", "HUX_WORKER_KEY")
values = [environ.get(name, "") for name in names]
for name in names:
key_file = environ.get(f"{name}_FILE", "")
if key_file and os.path.exists(key_file):
with open(key_file, encoding="utf-8", errors="replace") as handle:
values.append(handle.read(4097).strip())
path = environ.get("HUX_CANARY_FILE", "")
if path and os.path.exists(path):
with open(path, encoding="utf-8", errors="replace") as handle:
for line in handle:
_, _, value = line.strip().partition("=")
values.append(value.strip().strip("'\""))
return [v for v in values if len(v) >= 8]
def scrub_text(text: str, environ: dict[str, str] | None = None) -> tuple[str, list[str]]:
"""Replace secret-looking substrings with ``[redacted:<class>]``; return the text and the classes hit."""
hits: list[str] = []
for canary in canaries(environ):
if canary in text:
text = text.replace(canary, "[redacted:canary]")
hits.append("canary")
for name, pattern in SECRET_PATTERNS:
text, count = pattern.subn(f"[redacted:{name}]", text)
if count:
hits.append(name)
return text, hits
def scrub_value(value: Any, hits: list[str], environ: dict[str, str] | None = None) -> Any:
"""Scrub every string inside a JSON-like value, collecting classes into ``hits``.
Content hashes (``hash``, ``argument_hash``) are digests by construction and
are kept verbatim so the hex rule does not eat them.
"""
if isinstance(value, str):
text, found = scrub_text(value, environ)
hits.extend(found)
return text
if isinstance(value, list):
return [scrub_value(item, hits, environ) for item in value]
if isinstance(value, dict):
return {
str(key): item if key in HASH_KEYS and isinstance(item, str) else scrub_value(item, hits, environ)
for key, item in value.items()
}
return value
def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
"""Keep only the allowlisted keys for ``kind`` (SO-11) and apply the kind-specific shape rules."""
if not detail:
return {}
allowed = DETAIL_ALLOWLIST.get(kind, frozenset())
kept = {key: value for key, value in detail.items() if key in allowed}
if kind == "decision.plan" and "steps" in kept:
steps = kept["steps"] if isinstance(kept["steps"], list) else []
kept["steps"] = [str(step)[:200] for step in steps[:20]]
if kind == "tool.call":
# Raw arguments never persist: only their names, a hash and a byte count.
# F13d: normalise before the prefix check so ``workspace/../.env`` cannot
# pass as a workspace path; the stored value is the normalised one.
path = os.path.normpath(kept["target_path"]) if isinstance(kept.get("target_path"), str) else ""
if path.startswith(WORKSPACE_PREFIX + "/"):
kept["target_path"] = path
else:
kept.pop("target_path", None)
if "argument_names" in kept:
names = kept["argument_names"] if isinstance(kept["argument_names"], list) else []
kept["argument_names"] = sorted({str(name)[:80] for name in names})[:64]
return kept
def cap_detail(detail: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Replace detail over 32 KiB with a truncation marker (SO-13)."""
size = len(json.dumps(detail, sort_keys=True, separators=(",", ":")).encode())
if size > DETAIL_CAP_BYTES:
return {"truncated": True, "bytes": size}, True
return detail, False
def filter_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Keep well-formed refs, drop URIs outside the allowlisted schemes (SO-19), cap at 64."""
kept: list[dict[str, Any]] = []
for ref in evidence or []:
if not isinstance(ref, dict) or not isinstance(ref.get("kind"), str) or not isinstance(ref.get("id"), str):
continue
clean = {"kind": ref["kind"], "id": ref["id"][:200]}
uri = ref.get("uri")
if isinstance(uri, str) and uri.startswith(URI_SCHEMES):
clean["uri"] = uri[:2000]
if isinstance(ref.get("hash"), str):
clean["hash"] = ref["hash"]
kept.append(clean)
return kept[:64]
def derive_level(sensitivity: str, hits: list[str], truncated: bool) -> dict[str, str]:
"""Redaction block for a new event from its sensitivity and what the pipeline did."""
reasons: list[str] = []
level = "none"
if sensitivity == "sensitive":
level, reasons = "partial", ["sensitive topic"]
if hits:
level = "partial" if level == "none" else level
reasons.append("scrubbed " + ",".join(sorted(set(hits)))[:120])
if truncated:
level = "partial" if level == "none" else level
reasons.append("detail truncated")
if sensitivity == "restricted":
level, reasons = "full", ["restricted content"]
block = {"level": level}
if reasons:
block["reason"] = "; ".join(reasons)[:200]
return block
def redact_record(record: dict[str, Any], surface: str) -> dict[str, Any]:
"""Serve-time redaction (SO-14) for any record that carries ``redaction``; strips ``_meta`` (SO-51).
``none`` is served as stored except that telegram and voice are lifted to
``partial``; ``partial`` drops ``detail``; ``full`` keeps only the
contract-required envelope with the summary replaced by a placeholder.
"""
served = {key: value for key, value in record.items() if key != "_meta"}
block = served.get("redaction")
if not isinstance(block, dict):
return served
level = block.get("level", "none")
if level == "none" and surface in HUMAN_SURFACES:
level = "partial"
served["redaction"] = {"level": "partial", "reason": f"{surface} surface never receives unredacted detail"}
if level == "partial":
served.pop("detail", None)
if level == "full":
served = {key: value for key, value in served.items() if key in FULL_KEEP}
if "summary" in record:
served["summary"] = "[redacted]"
if "content" in record:
served["content"] = ""
return served
def full_redaction(record: dict[str, Any], summary: str, reason: str) -> dict[str, Any]:
"""Rewrite a stored event to ``redaction.level: full`` in place (forget and topic decay)."""
kept = {key: value for key, value in record.items() if key in FULL_KEEP}
kept["summary"] = summary[:SUMMARY_MAX] or "[redacted]"
kept["redaction"] = {"level": "full", "reason": reason[:200]}
if "idempotency_key" in record:
kept["idempotency_key"] = record["idempotency_key"]
return kept

View File

@ -0,0 +1,134 @@
"""Fail-closed authentication and policy checks for HUX-12 evidence.
The evidence producer is a loopback-only companion process. It receives a
dedicated Vault key and a non-secret policy file; neither the browser relay nor
the agent worker can use its trust class. This module deliberately performs no
network discovery: the producer supplies observations and this boundary binds
them to the configured Jenkins, Harbor, Flux, workload, pod and health facts.
"""
from __future__ import annotations
import json
import os
import re
import stat
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from hux.errors import Forbidden, Invalid
KEY_FILE_ENV = "HUX_RELEASE_EVIDENCE_KEY_FILE"
POLICY_FILE_ENV = "HUX_RELEASE_EVIDENCE_POLICY_FILE"
MAX_KEY_BYTES = 4096
MAX_POLICY_BYTES = 64 * 1024
POLICY_FIELDS = {"schema", "max_evidence_age_seconds", "workloads"}
WORKLOAD_FIELDS = {
"review_url_prefix", "jenkins_job_url", "image_repository",
"flux_kustomization", "health_url",
}
def _regular_file(path: str, modes: set[int], limit: int) -> bytes:
"""Read one bounded regular file without accepting a symlink or loose mode."""
if not path:
raise Invalid("HUX-12 evidence configuration is missing")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
descriptor = -1
try:
descriptor = os.open(Path(path), flags)
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) not in modes:
raise Invalid("HUX-12 evidence file permissions are invalid")
data = os.read(descriptor, limit + 1)
except OSError as error:
raise Invalid("HUX-12 evidence configuration is unavailable") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if not data or len(data) > limit:
raise Invalid("HUX-12 evidence file is empty or too large")
return data
def evidence_key(environ: Mapping[str, str]) -> str:
"""Return the file-only producer key; inline environment secrets are ignored."""
data = _regular_file(environ.get(KEY_FILE_ENV, ""), {0o400}, MAX_KEY_BYTES)
try:
key = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise Invalid("HUX-12 evidence key is invalid") from error
if len(key) < 32:
raise Invalid("HUX-12 evidence key is too short")
return key
def _https_url(value: Any, field: str, *, prefix: bool = False) -> str:
if not isinstance(value, str) or not value or len(value) > 500:
raise Invalid(f"{field} is missing or too long")
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise Invalid(f"{field} must be a credential-free HTTPS URL")
if prefix and not value.endswith("/"):
raise Invalid(f"{field} must end with /")
return value
def load_policy(environ: Mapping[str, str]) -> dict[str, Any]:
"""Load and strictly validate the non-secret producer policy."""
raw = _regular_file(environ.get(POLICY_FILE_ENV, ""), {0o400, 0o440, 0o444}, MAX_POLICY_BYTES)
try:
policy = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise Invalid("HUX-12 evidence policy is not valid JSON") from error
if not isinstance(policy, dict) or set(policy) != POLICY_FIELDS or policy.get("schema") != "hux.release_evidence_policy.v1":
raise Invalid("HUX-12 evidence policy shape is invalid")
age = policy.get("max_evidence_age_seconds")
workloads = policy.get("workloads")
if not isinstance(age, int) or isinstance(age, bool) or not 60 <= age <= 86400:
raise Invalid("HUX-12 evidence age bound is invalid")
if not isinstance(workloads, dict) or not workloads or len(workloads) > 16:
raise Invalid("HUX-12 workload policy is invalid")
for name, item in workloads.items():
if not isinstance(name, str) or not isinstance(item, dict) or set(item) != WORKLOAD_FIELDS:
raise Invalid("HUX-12 workload policy shape is invalid")
_https_url(item["review_url_prefix"], "review_url_prefix", prefix=True)
_https_url(item["jenkins_job_url"], "jenkins_job_url")
_https_url(item["health_url"], "health_url")
if not re.fullmatch(r"[a-z0-9./_-]+", str(item["image_repository"])):
raise Invalid("HUX-12 image repository is invalid")
if not re.fullmatch(r"[a-z0-9-]{1,63}", str(item["flux_kustomization"])):
raise Invalid("HUX-12 Flux kustomization is invalid")
return policy
def configured(environ: Mapping[str, str]) -> bool:
"""Whether producer authentication and policy are both healthy."""
try:
evidence_key(environ)
load_policy(environ)
except Invalid:
return False
return True
def workload_policy(environ: Mapping[str, str], workload: str) -> dict[str, Any]:
"""Return the exact allowlist for a workload or fail closed."""
item = load_policy(environ)["workloads"].get(workload)
if item is None:
raise Invalid("workload is not enabled for release evidence")
return item
def require_review_creator(trust: str) -> None:
"""Only the authenticated browser path may record the human review decision."""
if trust != "router":
raise Forbidden("reviewed releases require router trust")
def require_evidence_producer(trust: str) -> None:
"""Only the dedicated producer may advance a reviewed release."""
if trust != "evidence":
raise Forbidden("release transitions require evidence trust")

View File

@ -0,0 +1,337 @@
"""HUX-12 immutable, hash-chained deployment follow-through.
Each release is bound to one project and conversation. The current view is
reconstructed from append-only entries; every read verifies sequence, hash
chain and contract before returning anything. ``live_verified`` is reachable
only from a digest-converged pod with a passing health check.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
from urllib.parse import urlsplit
from hux import contracts, redaction, release_security
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.store import check_id, new_id, now_iso
CARD = "HUX-12"
FAMILY = "release_ledger"
MAX_RELEASES = 500
ZERO_HASH = "sha256:" + "0" * 64
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
WORKLOADS = frozenset({"hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"})
EVIDENCE_FIELDS = frozenset({"review_url", "merge_commit", "ci_build_url", "image_ref", "image_digest", "harbor_digest", "flux_revision", "pod_digest", "health_check", "rollback_target"})
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
COMMIT = re.compile(r"^[0-9a-f]{40}$")
IMAGE = re.compile(r"^[a-z0-9./-]+:[A-Za-z0-9._-]+@(sha256:[0-9a-f]{64})$")
SCHEMAS = contracts.load_all()
SCHEMAS["release-ledger.schema.json"] = contracts.load_schema("release-ledger.schema.json")
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected release fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, str]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation_id
def _actor(request: Request) -> dict[str, str]:
if request.identity.trust == "evidence":
return {"type": "system", "id": "release-evidence-producer"}
if request.identity.trust == "worker":
return {"type": "system", "id": "release-worker"}
return {"type": "user", "id": request.identity.subject}
def _safe_url(value: Any, *, relative: bool = False) -> str:
if not isinstance(value, str) or not value or len(value) > 500:
raise Invalid("evidence URL is missing or too long")
parsed = urlsplit(value)
if parsed.query or parsed.fragment or parsed.username or parsed.password:
raise Invalid("evidence URLs may not contain credentials, queries or fragments")
if relative:
if not ((not parsed.scheme and value.startswith("/")) or parsed.scheme == "https"):
raise Invalid("health URL must be HTTPS or a local absolute path")
elif parsed.scheme != "https" or not parsed.netloc:
raise Invalid("evidence URL must use HTTPS")
if redaction.scrub_text(value)[1]:
raise Invalid("evidence URL appears to contain a secret")
return value
def _canonical(data: Any) -> bytes:
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
def _entry_hash(entry: dict[str, Any]) -> str:
unsigned = {key: value for key, value in entry.items() if key != "entry_hash"}
return "sha256:" + hashlib.sha256(_canonical(unsigned)).hexdigest()
def _validate_release(release: dict[str, Any]) -> None:
problems = contracts.validate("release.schema.json", release, SCHEMAS)
if problems:
raise Invalid("release failed contract validation", problems)
def _validate_entry(entry: dict[str, Any]) -> None:
problems = contracts.validate("release-ledger.schema.json", entry, SCHEMAS)
if problems:
raise Invalid("release ledger entry failed contract validation", problems)
def _rows(request: Request, release_id: str) -> list[dict[str, Any]]:
try:
check_id(release_id)
except Invalid as error:
raise NotFound("release not found") from error
rows = request.store.read(FAMILY, release_id)
if not rows:
raise NotFound("release not found")
previous = ZERO_HASH
for sequence, row in enumerate(rows, 1):
if row.get("sequence") != sequence or row.get("previous_hash") != previous or row.get("entry_hash") != _entry_hash(row):
raise Invalid("release ledger integrity check failed")
_validate_entry(row)
previous = row["entry_hash"]
return rows
def _append(request: Request, release: dict[str, Any], project_id: str, conversation_id: str, previous: list[dict[str, Any]]) -> dict[str, Any]:
entry = {
"schema": "hux.release_ledger_entry.v1", "release_id": release["id"], "owner": request.identity.subject,
"project_id": project_id, "conversation_id": conversation_id, "sequence": len(previous) + 1,
"at": now_iso(), "previous_hash": previous[-1]["entry_hash"] if previous else ZERO_HASH, "snapshot": release,
}
entry["entry_hash"] = _entry_hash(entry)
_validate_entry(entry)
request.store.append(FAMILY, release["id"], entry)
return entry
def _view(entry: dict[str, Any]) -> dict[str, Any]:
return {
"release": entry["snapshot"],
"scope": {"project_id": entry["project_id"], "conversation_id": entry["conversation_id"]},
"revision": entry["sequence"],
"ledger_hash": entry["entry_hash"],
}
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(_canonical({"body": body, "path": path})).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for different release evidence")
rows = _rows(request, row["release_id"])
if row["sequence"] > len(rows):
raise Invalid("release idempotency ledger is inconsistent")
return _view(rows[row["sequence"] - 1])
return None
def _remember(request: Request, key: str, fingerprint: str, release_id: str, sequence: int) -> None:
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "release_id": release_id, "sequence": sequence, "at": now_iso()})
def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]:
evidence = body.get("evidence")
if not isinstance(evidence, dict) or set(evidence) != {"review_url"}:
raise Invalid("reviewed release requires exactly evidence.review_url")
return {"review_url": _safe_url(evidence["review_url"])}
def create_release(request: Request) -> Response:
"""Create the sole reviewed ledger for one scoped workload commit."""
release_security.require_review_creator(request.identity.trust)
body = _body(request, {"workload", "commit", "feature_flags", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
if request.if_match() != 0:
raise Invalid("If-Match: 0 is required when creating a release")
workload, commit = body.get("workload"), body.get("commit")
if workload not in WORKLOADS or not isinstance(commit, str) or not COMMIT.fullmatch(commit):
raise Invalid("workload and exact 40-character commit are required")
flags = body.get("feature_flags", [])
if not isinstance(flags, list) or len(flags) > 32:
raise Invalid("feature_flags must be a bounded list")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
releases = [name for name in request.store.ledgers(FAMILY) if name.startswith("rel_")]
if len(releases) >= MAX_RELEASES:
raise TooLarge("release ledger limit reached")
for name in releases:
entry = _rows(request, name)[0]
release = entry["snapshot"]
if entry["project_id"] == project_id and entry["conversation_id"] == conversation_id and release["workload"] == workload and release["commit"] == commit:
raise Conflict("a release already exists for this scoped workload commit")
release = {
"schema": "hux.release.v1", "id": new_id("rel"), "workload": workload, "feature_flags": flags,
"commit": commit, "state": "reviewed", "evidence": _initial_evidence(body), "transitions": [],
}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, [])
result = _view(entry)
_remember(request, key, fingerprint, release["id"], 1)
request.audit("releases.create", release["id"])
return Response(201, result, {"ETag": "1", "Cache-Control": "no-store"})
def _transition_evidence(current: dict[str, Any], target: str, supplied: Any) -> dict[str, Any]:
if not isinstance(supplied, dict) or set(supplied) - EVIDENCE_FIELDS:
raise Invalid("evidence must be a bounded object of known fields")
expected = {
"merged": {"merge_commit"},
"built": {"ci_build_url", "image_ref", "image_digest", "harbor_digest"},
"verified": set(),
"deployed": {"flux_revision"},
"converged": {"pod_digest"},
"live_verified": {"health_check"},
"rolled_back": {"rollback_target"},
}[target]
if set(supplied) != expected:
raise Invalid(f"{target} requires exactly: {', '.join(sorted(expected)) or 'no new evidence'}")
evidence = {**current.get("evidence", {}), **supplied}
if target == "merged" and not COMMIT.fullmatch(str(evidence.get("merge_commit", ""))):
raise Invalid("merged requires an exact merge commit")
if target == "built":
match = IMAGE.fullmatch(str(evidence.get("image_ref", "")))
digest = evidence.get("image_digest")
if not match or not SHA.fullmatch(str(digest)) or evidence.get("harbor_digest") != digest or match.group(1) != digest:
raise Invalid("image ref, image digest and Harbor digest must match exactly")
evidence["ci_build_url"] = _safe_url(evidence.get("ci_build_url"))
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
raise Invalid("verified image does not match Harbor")
if target == "deployed" and not re.fullmatch(r"main@sha1:[0-9a-f]{40}", str(evidence.get("flux_revision", ""))):
raise Invalid("deployed requires an exact Flux revision")
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
raise Invalid("pod digest does not equal the built image digest")
if target == "live_verified":
health = evidence.get("health_check")
if not isinstance(health, dict) or set(health) != {"url", "status", "at"} or health.get("status") != "pass":
raise Invalid("live verification requires one passing health check")
health["url"] = _safe_url(health["url"], relative=True)
if target == "rolled_back" and not SHA.fullmatch(str(evidence.get("rollback_target", ""))):
raise Invalid("rollback requires an exact target digest")
return evidence
def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str]]:
mapping = {
"merged": ("build", "merge_commit"), "built": ("build", "image_digest"),
"verified": ("build", "harbor_digest"), "deployed": ("flux", "flux_revision"),
"converged": ("pod", "pod_digest"), "live_verified": ("receipt", "health_check"),
"rolled_back": ("pod", "rollback_target"),
}
kind, key = mapping[target]
value = evidence[key]
identifier = value["at"] if key == "health_check" else value
return [{"kind": kind, "id": str(identifier)[:200]}]
def transition_release(request: Request) -> Response:
"""Append exactly one legal, evidence-backed release transition."""
release_security.require_evidence_producer(request.identity.trust)
body = _body(request, {"to", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
rows = _rows(request, request.params["release_id"])
head, current = rows[-1], rows[-1]["snapshot"]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
if expected != len(rows):
raise Conflict("release revision does not match If-Match")
target = body.get("to")
if target == "rolled_back":
if current["state"] not in {"built", "verified", "deployed", "converged", "live_verified"}:
raise Conflict("there is no deployed image to roll back")
elif current["state"] == "rolled_back" or target not in ORDER or ORDER.index(target) != ORDER.index(current["state"]) + 1:
raise Conflict("release transition skips, reverses, or follows a terminal state")
evidence = _transition_evidence(current, target, body.get("evidence"))
stamp = now_iso()
transition = {"from": current["state"], "to": target, "at": stamp, "by": _actor(request), "evidence": _evidence_refs(target, evidence)}
release = {**current, "state": target, "evidence": evidence, "transitions": [*current["transitions"], transition]}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, rows)
result = _view(entry)
_remember(request, key, fingerprint, release["id"], entry["sequence"])
request.audit("releases.transition", release["id"], reason=target)
return Response(200, result, {"ETag": str(entry["sequence"]), "Cache-Control": "no-store"})
def get_release(request: Request) -> Response:
"""Read one verified release view within its project/conversation scope."""
project_id, conversation_id = _scope(request)
rows = _rows(request, request.params["release_id"])
head = rows[-1]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
request.audit("releases.read", request.params["release_id"])
return Response(200, _view(head), {"ETag": str(len(rows)), "Cache-Control": "no-store"})
def list_releases(request: Request) -> Response:
"""List verified heads for one scope, newest ledger first."""
project_id, conversation_id = _scope(request)
items = []
for name in request.store.ledgers(FAMILY):
if not name.startswith("rel_"):
continue
head = _rows(request, name)[-1]
if head["project_id"] == project_id and head["conversation_id"] == conversation_id:
items.append(_view(head))
items.sort(key=lambda row: row["release"]["transitions"][-1]["at"] if row["release"]["transitions"] else "", reverse=True)
request.audit("releases.list", conversation_id)
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-12 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/releases"
router.add("POST", base, CARD, "releases.create", create_release)
router.add("GET", base, CARD, "releases.list", list_releases)
router.add("GET", base + "/{release_id}", CARD, "releases.read", get_release)
router.add("POST", base + "/{release_id}/transitions", CARD, "releases.transition", transition_release)

View File

@ -0,0 +1,384 @@
"""HUX-08 research model: sources, passages, citations and notebooks.
A source says where evidence came from, a passage is the exact excerpt, a
citation ties a claim in a message to passages with a support verdict, and a
notebook collects them per research question. The service never fetches a
``uri`` (SO-19, SO-29): URIs are stored metadata under an allowlisted scheme.
Every referenced id resolves under the caller's own subtree (SO-33); a foreign
or unknown id is ``404`` so ids cannot be probed. Duplicates are merged on a
server-computed ``dedupe_key`` and the original record is returned.
"""
from __future__ import annotations
import hashlib
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from hux.artifacts import remember, replay
from hux import redaction
from hux.contracts import load_all, validate_record
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
FAMILY = "research"
SOURCES, PASSAGES, CITATIONS, NOTEBOOKS = "sources", "passages", "citations", "notebooks"
SCHEMES = frozenset({"http", "https", "artifact", "memory", "file"})
SOURCE_KINDS = ("web", "document", "artifact", "memory", "tool_output", "dataset")
CLASSIFICATIONS = ("primary", "secondary", "unknown")
SUPPORT = ("supports", "partially_supports", "contradicts", "unverified")
NOTEBOOK_TRANSITIONS = {"open": frozenset({"answered", "abandoned"}), "answered": frozenset(), "abandoned": frozenset()}
MAX_SOURCES, MAX_PASSAGES = 10000, 20000
_SCHEMAS: dict[str, dict[str, Any]] = {}
def _schemas() -> dict[str, dict[str, Any]]:
if not _SCHEMAS:
_SCHEMAS.update(load_all())
return _SCHEMAS
def _check(record: dict[str, Any]) -> None:
problems = validate_record(record, _schemas())
if problems:
raise Invalid(f"{record.get('schema')} record is not valid", problems)
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
value = body.get(key)
if value is None:
if required:
raise Invalid(f"{key} is required")
return None
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
return value
def _clean(value: Any) -> Any:
"""Secret-scrub free text before it is stored (F9); ids and enums never carry secrets so they skip this."""
return redaction.scrub_value(value, [])
def _sha(*parts: str) -> str:
return "sha256:" + hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
def _lookup(store: TenantStore, ledger: str, key: str) -> str | None:
for row in store.read(FAMILY, ledger):
if row.get("key") == key:
return row["id"]
return None
def _resolve(store: TenantStore, family: str, record_id: Any) -> dict[str, Any]:
"""Load a record under the caller's subtree; malformed or missing is 404 (SO-33).
Sources, passages and citations are immutable, so the store's ``revision``
bookkeeping is stripped before they are served; notebooks keep theirs.
"""
try:
record = store.get(family, check_id(record_id))
except (Invalid, NotFound) as error:
raise NotFound(f"{family[:-1]} not found") from error
return record if family == NOTEBOOKS else {k: v for k, v in record.items() if k != "revision"}
def _id_list(body: dict[str, Any], key: str, maximum: int = 32) -> list[str]:
values = body.get(key, [])
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
raise Invalid(f"{key} must be a list of ids")
unique = list(dict.fromkeys(values))
if len(unique) > maximum:
raise TooLarge(f"{key} exceeds {maximum} ids")
return unique
def _emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, detail: dict[str, Any], evidence: list[dict[str, Any]]) -> None:
if conversation_id is None:
return
try:
from hux.events import emit
except ModuleNotFoundError:
return
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence)
# -- sources ---------------------------------------------------------------------
def normalise_uri(uri: str) -> str:
"""Canonical form used for dedupe: lowercase scheme and host, no fragment, no trailing slash."""
parts = urlsplit(uri.strip())
if parts.scheme.lower() not in SCHEMES:
raise Invalid("uri scheme must be one of http, https, artifact, memory, file")
path = parts.path.rstrip("/") or ("/" if parts.netloc else "")
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
def create_source(request: Request) -> Response:
"""``POST /hux/v1/sources``: record where evidence came from; duplicates return the original."""
body = _body(request)
key = request.idempotency_key()
kind = _string(body, "kind", 40)
if kind not in SOURCE_KINDS:
raise Invalid("unknown source kind")
classification = body.get("classification", "unknown")
if classification not in CLASSIFICATIONS:
raise Invalid("unknown classification")
title = _clean(_string(body, "title", 300))
uri = _string(body, "uri", 2000, required=False)
dedupe = _sha("uri", normalise_uri(uri)) if uri is not None else _sha("title", kind, title.strip().lower())
stamp = now_iso()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "source_keys", dedupe)
if existing is not None:
request.audit("research.source_create", existing, reason="deduplicated")
return Response(200, _resolve(request.store, SOURCES, existing), {"HUX-Replayed": "true"})
if request.store.count(SOURCES) >= MAX_SOURCES:
raise TooLarge("tenant has reached 10000 sources")
record: dict[str, Any] = {
"schema": "hux.source.v1", "id": new_id("src"), "kind": kind, "title": title,
"classification": classification, "retrieved_at": stamp, "dedupe_key": dedupe,
"provenance": {"surface": request.identity.surface, "actor": _actor(request.identity), "recorded_at": stamp},
}
if uri is not None:
record["uri"] = uri
for field, limit in (("publisher", 200), ("published_at", 40), ("content_hash", 71)):
if _string(body, field, limit, required=False) is not None:
record[field] = body[field]
conversation_id = body.get("conversation_id")
if conversation_id is not None:
record["provenance"]["conversation_id"] = check_id(conversation_id)
_check(record)
stored = record | {}
request.store.put(SOURCES, record)
request.store.append(FAMILY, "source_keys", {"key": dedupe, "id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.source_create", stored["id"])
return Response(201, stored)
def _actor(identity: Identity) -> dict[str, str]:
if identity.trust == "worker":
return {"type": "system", "id": "worker"}
return {"type": "user", "id": identity.subject}
def get_source(request: Request) -> Response:
"""``GET /hux/v1/sources/{id}``: one source record."""
record = _resolve(request.store, SOURCES, request.params["id"])
request.audit("research.source_get", record["id"])
return Response(200, record)
# -- passages --------------------------------------------------------------------
def create_passage(request: Request) -> Response:
"""``POST /hux/v1/passages``: an exact excerpt of a source; hash and dedupe key are server-computed."""
body = _body(request)
key = request.idempotency_key()
source = _resolve(request.store, SOURCES, body.get("source_id"))
text = _clean(_string(body, "text", 4000))
text_hash = "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
dedupe = _sha("passage", source["id"], text_hash)
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "passage_keys", dedupe)
if existing is not None:
request.audit("research.passage_create", existing, reason="deduplicated")
return Response(200, _resolve(request.store, PASSAGES, existing), {"HUX-Replayed": "true"})
if request.store.count(PASSAGES) >= MAX_PASSAGES:
raise TooLarge("tenant has reached 20000 passages")
record: dict[str, Any] = {"schema": "hux.passage.v1", "id": new_id("psg"), "source_id": source["id"], "text": text, "hash": text_hash, "dedupe_key": dedupe}
if body.get("locator") is not None:
record["locator"] = body["locator"]
_check(record)
stored = record | {}
request.store.put(PASSAGES, record)
request.store.append(FAMILY, "passage_keys", {"key": dedupe, "id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.passage_create", stored["id"])
return Response(201, stored)
# -- citations -------------------------------------------------------------------
def _message_ledger(message_id: str) -> str:
return "msg_" + hashlib.sha256(message_id.encode("utf-8")).hexdigest()[:40]
def attach_citation(request: Request) -> Response:
"""``POST /hux/v1/messages/{id}/citations``: tie a claim to passages with a support verdict."""
body = _body(request)
key = request.idempotency_key()
message_id = request.params["id"]
if len(message_id) > 120:
raise Invalid("message id is too long")
claim = _clean(_string(body, "claim", 1000))
support = body.get("support", "unverified")
if support not in SUPPORT:
raise Invalid("unknown support verdict")
passage_ids = _id_list(body, "passage_ids")
if not passage_ids:
raise Invalid("at least one passage_id is required")
passages = [_resolve(request.store, PASSAGES, pid) for pid in passage_ids]
dedupe = _sha("citation", message_id, claim, *sorted(passage_ids))
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "citation_keys", dedupe)
if existing is not None:
request.audit("research.citation_attach", existing, reason="deduplicated")
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
if _string(body, "note", 500, required=False) is not None:
record["note"] = _clean(body["note"])
_check(record)
stored = record | {}
request.store.put(CITATIONS, record)
request.store.append(FAMILY, "citation_keys", {"key": dedupe, "id": stored["id"]})
request.store.append(FAMILY, _message_ledger(message_id), {"id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.citation_attach", stored["id"])
evidence = [{"kind": "passage", "id": p["id"], "hash": p["hash"]} for p in passages]
conversation_id = body.get("conversation_id")
_emit(request.store, request.identity, None if conversation_id is None else check_id(conversation_id), "citation.attached", f"Citation attached ({support})", {"message_id": message_id, "citation_id": stored["id"], "support": support}, evidence)
return Response(201, stored)
def list_citations(request: Request) -> Response:
"""``GET /hux/v1/messages/{id}/citations``: citations with passages and sources embedded."""
items = []
for row in request.store.read(FAMILY, _message_ledger(request.params["id"])):
citation = _resolve(request.store, CITATIONS, row["id"])
passages = [_resolve(request.store, PASSAGES, pid) for pid in citation["passage_ids"]]
sources = {p["source_id"]: _resolve(request.store, SOURCES, p["source_id"]) for p in passages}
items.append({"citation": citation, "passages": passages, "sources": list(sources.values())})
request.audit("research.citation_list", request.params["id"])
return page(items)
def validate_citations(store: TenantStore, citation_ids: list[str]) -> list[str]:
"""Integrity problems for a set of citations: dangling passages, passage/source mismatch, contradictions without a note."""
problems: list[str] = []
for citation_id in citation_ids:
try:
citation = _resolve(store, CITATIONS, citation_id)
except NotFound:
problems.append(f"{citation_id}: citation does not resolve")
continue
if citation["support"] == "contradicts" and not citation.get("note"):
problems.append(f"{citation_id}: contradicts without a note")
for passage_id in citation["passage_ids"]:
try:
passage = _resolve(store, PASSAGES, passage_id)
except NotFound:
problems.append(f"{citation_id}: passage {passage_id} does not resolve")
continue
if not store.exists(SOURCES, passage["source_id"]):
problems.append(f"{citation_id}: passage {passage_id} names missing source {passage['source_id']}")
return problems
# -- notebooks -------------------------------------------------------------------
def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
values = body.get(key)
if values is None:
return current
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
raise Invalid(f"{key} must be a list of non-empty strings")
return _clean(values)
def create_notebook(request: Request) -> Response:
"""``POST /hux/v1/notebooks``: open a research question for a conversation."""
body = _body(request)
key = request.idempotency_key()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key)
if existing is not None:
request.audit("research.notebook_create", existing, reason="idempotent_replay")
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
"question": _clean(_string(body, "question", 1000)), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
"assumptions": _text_list(body, "assumptions", []), "unresolved_questions": _text_list(body, "unresolved_questions", []),
"updated_at": now_iso(), "notes": [], "revision": 1,
}
_check(record)
stored = request.store.put(NOTEBOOKS, record)
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.notebook_create", stored["id"])
return Response(201, stored, {"ETag": "1"})
def get_notebook(request: Request) -> Response:
"""``GET /hux/v1/notebooks/{id}``: one notebook."""
record = _resolve(request.store, NOTEBOOKS, request.params["id"])
request.audit("research.notebook_get", record["id"])
return Response(200, record, {"ETag": str(record["revision"])})
def _merged_ids(store: TenantStore, family: str, current: list[str], body: dict[str, Any], key: str) -> list[str]:
added = _id_list(body, key, maximum=500)
for record_id in added:
_resolve(store, family, record_id)
return list(dict.fromkeys([*current, *added]))
def patch_notebook(request: Request) -> Response:
"""``PATCH /hux/v1/notebooks/{id}``: add references and notes, replace assumptions, move status (If-Match required)."""
body = _body(request)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
with request.store.lock(FAMILY):
notebook = _resolve(request.store, NOTEBOOKS, request.params["id"])
if expected != notebook["revision"]:
raise Conflict(f"revision {expected} does not match current revision {notebook['revision']}", [str(notebook["revision"])])
stamp = now_iso()
updated = {
**notebook,
"source_ids": _merged_ids(request.store, SOURCES, notebook["source_ids"], body, "add_source_ids"),
"passage_ids": _merged_ids(request.store, PASSAGES, notebook["passage_ids"], body, "add_passage_ids"),
"citation_ids": _merged_ids(request.store, CITATIONS, notebook["citation_ids"], body, "add_citation_ids"),
"assumptions": _text_list(body, "assumptions", notebook["assumptions"]),
"unresolved_questions": _text_list(body, "unresolved_questions", notebook["unresolved_questions"]),
"updated_at": stamp,
}
notes = body.get("add_notes", [])
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
raise Invalid("add_notes must be a list of objects")
for note in notes:
entry = {"at": stamp, "text": _clean(_string(note, "text", 2000))}
if note.get("source_id") is not None:
entry["source_id"] = _resolve(request.store, SOURCES, note["source_id"])["id"]
updated["notes"] = [*updated["notes"], entry]
status = body.get("status")
if status is not None and status != notebook["status"]:
if status not in NOTEBOOK_TRANSITIONS or status not in NOTEBOOK_TRANSITIONS[notebook["status"]]:
raise Conflict(f"notebook cannot move from {notebook['status']} to {status}")
updated["status"] = status
_check(updated)
stored = request.store.put(NOTEBOOKS, updated, expected_revision=expected)
request.audit("research.notebook_patch", stored["id"])
return Response(200, stored, {"ETag": str(stored["revision"])})
def register(router: Router) -> None:
"""Attach HUX-08 routes."""
card = "HUX-08"
router.add("POST", "/hux/v1/sources", card, "research.source_create", create_source)
router.add("GET", "/hux/v1/sources/{id}", card, "research.source_get", get_source)
router.add("POST", "/hux/v1/passages", card, "research.passage_create", create_passage)
router.add("POST", "/hux/v1/messages/{id}/citations", card, "research.citation_attach", attach_citation)
router.add("GET", "/hux/v1/messages/{id}/citations", card, "research.citation_list", list_citations)
router.add("POST", "/hux/v1/notebooks", card, "research.notebook_create", create_notebook)
router.add("GET", "/hux/v1/notebooks/{id}", card, "research.notebook_get", get_notebook)
router.add("PATCH", "/hux/v1/notebooks/{id}", card, "research.notebook_patch", patch_notebook)

View File

@ -0,0 +1,138 @@
"""Bounded in-process HUX-10 retention scheduling for one tenant pod."""
from __future__ import annotations
import hashlib
import threading
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from hux import privacy
from hux.errors import Unauthorized
from hux.identity import SLOT_RE, Identity, _read_subject_binding
from hux.store import TenantStore
DAY_SECONDS = 86_400.0
MAX_JITTER_SECONDS = 3_600
MAX_BACKOFF_SECONDS = 900.0
class BindingUnavailable(ValueError):
"""The projected subject binding is absent or unsafe."""
def subject_binding(environ: Mapping[str, str]) -> Identity:
"""Read the canonical edge-published binding and combine it with the configured tenant slot."""
slot = environ.get("HUX_TENANT_SLOT", "")
raw_path = environ.get("HUX_SUBJECT_BINDING_FILE", "")
if not SLOT_RE.fullmatch(slot) or not raw_path:
raise BindingUnavailable("binding configuration unavailable")
try:
subject = _read_subject_binding(Path(raw_path))
except Unauthorized as error:
raise BindingUnavailable("binding file unavailable") from error
if subject is None:
raise BindingUnavailable("binding file unavailable")
return Identity(slot, subject, "worker", "worker")
class RetentionScheduler:
"""Run privacy retention without leaving the API process or tenant lock domain."""
def __init__(
self,
router: Any,
*,
clock: Callable[[], datetime] | None = None,
runner: Callable[..., dict[str, Any]] | None = None,
waiter: Callable[[float], bool] | None = None,
daily_seconds: float = DAY_SECONDS,
) -> None:
self.router = router
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.runner = runner or privacy.run_retention
self.daily_seconds = max(1.0, daily_seconds)
self._stop = threading.Event()
self.waiter = waiter or self._stop.wait
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._health: dict[str, Any] = {
"enabled": bool(router.flags.enabled("HUX-10")),
"status": "starting" if router.flags.enabled("HUX-10") else "disabled",
"last_run": None,
"last_success": None,
"errors": 0,
}
def health(self) -> dict[str, Any]:
"""Return bounded, credential-free scheduler evidence."""
with self._lock:
return dict(self._health)
def _set(self, **changes: Any) -> None:
with self._lock:
self._health.update(changes)
def _jitter(self, identity: Identity) -> float:
try:
cap = int(self.router.environ.get("HUX_RETENTION_JITTER_SECONDS", "900"))
except ValueError:
cap = 900
cap = min(MAX_JITTER_SECONDS, max(0, cap))
if cap == 0:
return 0.0
digest = hashlib.sha256(f"{identity.tenant_slot}\0{identity.subject}".encode()).digest()
return float(int.from_bytes(digest[:4], "big") % (cap + 1))
@staticmethod
def _backoff(failures: int) -> float:
return min(MAX_BACKOFF_SECONDS, float(5 * (2 ** min(max(failures - 1, 0), 7))))
def run(self) -> None:
"""Wait for a binding, run stale startup work, then run once per day."""
if not self.health()["enabled"]:
return
failures = 0
first = True
while not self._stop.is_set():
try:
identity = subject_binding(self.router.environ)
tenant = TenantStore(self.router.data_root, identity)
now = self.clock()
ran = not first or privacy.audit_stale(tenant, now)
if ran:
self.runner(tenant, now, identity)
stamp = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self._set(last_run=stamp, last_success=stamp)
failures = 0
first = False
self._set(status="healthy", errors=0)
if self.waiter(self.daily_seconds + self._jitter(identity)):
break
except BindingUnavailable:
failures += 1
self._set(status="waiting_for_binding", errors=failures)
if self.waiter(self._backoff(failures)):
break
except Exception: # noqa: BLE001 - background failure cannot take down the API
failures += 1
self._set(status="error", errors=failures)
if self.waiter(self._backoff(failures)):
break
self._set(status="stopped")
def start(self) -> None:
"""Start one daemon thread when HUX-10 is enabled."""
if not self.health()["enabled"] or self._thread is not None:
return
self._thread = threading.Thread(target=self.run, name="hux-retention", daemon=True)
self._thread.start()
def stop(self) -> None:
"""Wake and join the scheduler during server shutdown."""
self._stop.set()
if self._thread is not None and self._thread is not threading.current_thread():
self._thread.join(timeout=2.0)
self._set(status="stopped")

View File

@ -13,7 +13,7 @@ import os
from datetime import datetime, timedelta, timezone
from typing import Any
from hux_contracts import load_flags
from hux.contracts import load_flags
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
@ -44,11 +44,12 @@ RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
)
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_EXTERNAL = frozenset({"network", "deploy"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
"fast": {
@ -123,11 +124,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
topic = entry.get("topic", "general")
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
problems.append("restricted content may not be remembered")
if sensitivity == "sensitive" and entry.get("approval_mode") != "ask":
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
problems.append("sensitive memory requires approval_mode=ask")
if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never":
if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never":
problems.append("sensitive memory must expire or decay")
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected":
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored:
problems.append(f"topic {topic} may not be written to memory")
ttl = entry.get("ttl", {})
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
@ -140,12 +142,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask|deny. Deploy always asks."""
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
for capability in CAPABILITIES:
if capability == "deploy":
if capability in _ALWAYS_ASK:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
@ -171,7 +173,7 @@ def effective_decision(policy: dict[str, Any], capability: str, now: datetime |
if grant["decision"] == "deny":
return "deny"
decision = grant["decision"]
if capability == "deploy" and decision == "allow":
if capability in _ALWAYS_ASK and decision == "allow":
return "ask"
return decision

View File

@ -0,0 +1,52 @@
"""Process entry point: ``python -m hux.server``.
Environment: ``HUX_DATA_ROOT`` (tenant PVC path, default /opt/data),
``HUX_BIND`` (default 127.0.0.1), ``HUX_PORT`` (default 8790), ``HUX_FLAGS``,
``HUX_RELAY_KEY``, ``HUX_WORKER_KEY``, ``HUX_BUILD_COMMIT``, ``HUX_IMAGE_DIGEST``.
"""
from __future__ import annotations
import importlib
import os
from pathlib import Path
from hux.http import Router, serve
FAMILIES = (
"foundation", "events", "memory", "privacy", "organization", "artifacts",
"modes", "research", "policy", "multimodal", "suggestions", "releases",
)
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
"""Create a router with every required family registered and route-bound to its flag."""
router = Router(data_root, environ)
for name in FAMILIES:
module = importlib.import_module(f"hux.{name}")
module.register(router)
router.bind_capability_routes()
return router
def bind_address(env: dict[str, str]) -> str:
"""Loopback only: the router is in the same pod, nothing else may reach the service."""
bind = env.get("HUX_BIND", "127.0.0.1")
if bind not in {"127.0.0.1", "::1", "localhost"}:
raise SystemExit(f"refusing to bind {bind}: hux-foundation is loopback-only")
return bind
def main() -> None:
"""Run the server until killed."""
env = dict(os.environ)
router = build_router(Path(env.get("HUX_DATA_ROOT", "/opt/data")), env)
server = serve(router, bind_address(env), int(env.get("HUX_PORT", "8790")))
try:
server.serve_forever()
finally:
server.server_close()
if __name__ == "__main__": # pragma: no cover
main()

View File

@ -0,0 +1,223 @@
"""Per-tenant durable storage on the tenant PVC.
Every path is derived from the caller's tenant slot and hashed subject, so
there is no shared cross-tenant store and no way to address another tenant's
data. Records are JSON documents with a ``revision`` for optimistic
concurrency; ledgers are append-only JSONL. Writes are atomic (temp file +
fsync + rename) and guarded by one lock per family per tenant.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from collections.abc import Iterator
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.identity import Identity
ID_RE = re.compile(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$")
LAYOUT_VERSION = 1
MAX_RECORD_BYTES = 256 * 1024
MAX_FAMILY_RECORDS = 20000
def now_iso() -> str:
"""RFC 3339 UTC timestamp with second precision and a Z suffix."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def new_id(prefix: str) -> str:
"""Opaque, prefix-typed identifier matching common.schema.json#/$defs/id."""
return f"{prefix}_{os.urandom(8).hex()}{int(time.time() * 1000) % 100000:05d}"
def check_id(value: Any) -> str:
"""Return ``value`` when it is a well-formed record id, else raise Invalid."""
if not isinstance(value, str) or not ID_RE.match(value):
raise Invalid("malformed id")
return value
def _atomic_write(path: Path, data: bytes) -> None:
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
with open(tmp, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
class TenantStore:
"""Files for exactly one (tenant slot, subject) pair."""
_locks: dict[str, threading.RLock] = {}
_locks_guard = threading.Lock()
def __init__(self, root: Path, identity: Identity) -> None:
self.identity = identity
self.root = Path(root) / "hux" / f"v{LAYOUT_VERSION}" / identity.tenant_slot / identity.subject
self.root.mkdir(parents=True, exist_ok=True)
# -- locking -----------------------------------------------------------
def lock(self, family: str) -> threading.RLock:
"""One re-entrant lock per family per tenant directory."""
key = f"{self.root}:{family}"
with self._locks_guard:
return self._locks.setdefault(key, threading.RLock())
# -- documents ---------------------------------------------------------
def _doc_path(self, family: str, record_id: str) -> Path:
directory = self.root / family
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{check_id(record_id)}.json"
def get(self, family: str, record_id: str) -> dict[str, Any]:
"""Read one document or raise NotFound."""
path = self._doc_path(family, record_id)
if not path.exists():
raise NotFound(f"{family} {record_id} not found")
return json.loads(path.read_bytes())
def exists(self, family: str, record_id: str) -> bool:
"""True when the document is present."""
return self._doc_path(family, record_id).exists()
def put(self, family: str, record: dict[str, Any], expected_revision: int | None = None) -> dict[str, Any]:
"""Create or replace a document, bumping ``revision``.
``expected_revision`` implements If-Match: it must equal the stored
revision (or be None for a create of a new id) or Conflict is raised.
"""
record_id = check_id(record.get("id"))
with self.lock(family):
path = self._doc_path(family, record_id)
current = json.loads(path.read_bytes()) if path.exists() else None
if current is None:
if expected_revision not in (None, 0):
raise Conflict("record does not exist yet")
if self.count(family) >= MAX_FAMILY_RECORDS:
raise TooLarge(f"{family} is full")
revision = 1
else:
if expected_revision is not None and expected_revision != current.get("revision"):
raise Conflict(f"revision {expected_revision} does not match current revision {current.get('revision')}")
revision = int(current.get("revision", 0)) + 1
stored = {**record, "revision": revision}
data = json.dumps(stored, sort_keys=True, separators=(",", ":")).encode()
if len(data) > MAX_RECORD_BYTES:
raise TooLarge("record exceeds size bound")
_atomic_write(path, data)
return stored
def delete(self, family: str, record_id: str) -> None:
"""Remove a document; missing is not an error."""
path = self._doc_path(family, record_id)
with self.lock(family):
if path.exists():
path.unlink()
def count(self, family: str) -> int:
"""Number of documents in a family."""
directory = self.root / family
return sum(1 for p in directory.glob("*.json")) if directory.exists() else 0
def scan(self, family: str) -> Iterator[dict[str, Any]]:
"""Yield every document in a family, oldest file first."""
directory = self.root / family
if not directory.exists():
return
for path in sorted(directory.glob("*.json"), key=lambda p: (p.stat().st_mtime_ns, p.name)):
yield json.loads(path.read_bytes())
# -- ledgers -----------------------------------------------------------
def _ledger_path(self, family: str, name: str) -> Path:
directory = self.root / family
directory.mkdir(parents=True, exist_ok=True)
if not re.match(r"^[A-Za-z0-9._-]{1,120}$", name):
raise Invalid("malformed ledger name")
return directory / f"{name}.jsonl"
def append(self, family: str, name: str, record: dict[str, Any]) -> None:
"""Append one JSON line with fsync."""
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + b"\n"
if len(line) > MAX_RECORD_BYTES:
raise TooLarge("ledger record exceeds size bound")
with self.lock(family):
path = self._ledger_path(family, name)
with open(path, "ab") as handle:
handle.write(line)
handle.flush()
os.fsync(handle.fileno())
def read(self, family: str, name: str) -> list[dict[str, Any]]:
"""Read a whole ledger; a torn trailing line from a crash is dropped."""
path = self._ledger_path(family, name)
if not path.exists():
return []
rows: list[dict[str, Any]] = []
for line in path.read_bytes().split(b"\n"):
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
break
return rows
def ledgers(self, family: str) -> list[str]:
"""Names of the ledgers in a family."""
directory = self.root / family
return sorted(p.stem for p in directory.glob("*.jsonl")) if directory.exists() else []
def rewrite(self, family: str, name: str, rows: list[dict[str, Any]]) -> None:
"""Replace a ledger atomically (used by retention purges)."""
data = b"".join(json.dumps(r, sort_keys=True, separators=(",", ":")).encode() + b"\n" for r in rows)
with self.lock(family):
_atomic_write(self._ledger_path(family, name), data)
# -- blobs -------------------------------------------------------------
def put_blob(self, digest: str, data: bytes) -> Path:
"""Store content-addressed bytes; the caller has already verified the digest."""
if not re.match(r"^[0-9a-f]{64}$", digest):
raise Invalid("malformed digest")
directory = self.root / "blobs" / digest[:2]
directory.mkdir(parents=True, exist_ok=True)
path = directory / digest
if not path.exists():
_atomic_write(path, data)
return path
def get_blob(self, digest: str) -> bytes:
"""Read content-addressed bytes or raise NotFound."""
if not re.match(r"^[0-9a-f]{64}$", digest):
raise Invalid("malformed digest")
path = self.root / "blobs" / digest[:2] / digest
if not path.exists():
raise NotFound("blob not found")
return path.read_bytes()
# -- manifest ----------------------------------------------------------
def manifest(self, contract_version: str) -> dict[str, Any]:
"""Read or create the ``hux.manifest.v1`` for this tenant directory."""
path = self.root / "MANIFEST.json"
with self.lock("manifest"):
if path.exists():
return json.loads(path.read_bytes())
stamp = now_iso()
record = {
"schema": "hux.manifest.v1",
"contract_version": contract_version,
"data_layout_version": LAYOUT_VERSION,
"min_reader_contract_version": "1.0.0",
"created_at": stamp,
"updated_at": stamp,
}
_atomic_write(path, json.dumps(record, sort_keys=True).encode())
return record

View File

@ -0,0 +1,237 @@
"""HUX-09 restrained contextual suggestions with server-owned suppression.
Clients submit a bounded context signal; they cannot create suggestion text,
change cooldowns, or claim that a suggestion was clicked. Private and
sensitive conversations return an explicit no-store result before any
suggestion state or idempotency record is written.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import now_iso
CARD = "HUX-09"
FAMILY = "suggestion_states"
MAX_STATES = 1000
CONTEXTS = ("first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle")
CATALOG: dict[str, dict[str, Any]] = {
"first_session": {"id": "sug_first_session", "kind": "tip", "title": "Choose how Hermes works", "body": "Pick Fast, Thoughtful, Research, Create, or Private/local for this conversation.", "action": {"type": "open_mode"}, "priority": 80},
"empty_project": {"id": "sug_empty_project", "kind": "project", "title": "Give this project a starting point", "body": "Add a goal or move a related conversation here when it is useful.", "action": {"type": "create_project"}, "priority": 50},
"after_artifact": {"id": "sug_after_artifact", "kind": "feature", "title": "Keep working on this artifact", "body": "Open the artifact workspace to compare versions or continue editing.", "action": {"type": "open_artifacts"}, "priority": 70},
"after_research": {"id": "sug_after_research", "kind": "workflow", "title": "Keep the research together", "body": "Save the sources, assumptions, and open questions as a reusable workflow.", "action": {"type": "start_workflow"}, "priority": 65},
"after_approval": {"id": "sug_after_approval", "kind": "tip", "title": "Review autonomy controls", "body": "You can adjust what Hermes asks before doing for this conversation.", "action": {"type": "none"}, "priority": 40},
"idle": {"id": "sug_idle_workflow", "kind": "workflow", "title": "Make repeated work reusable", "body": "If this repeats, Hermes can turn it into a workflow without running it now.", "action": {"type": "start_workflow"}, "priority": 20},
}
SCHEMAS = contracts.load_all()
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected suggestion fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, dict[str, Any]]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation
def _privacy_gate(request: Request, conversation: dict[str, Any], body: dict[str, Any]) -> str | None:
from hux import privacy
if conversation.get("mode") == "private":
return "private_mode"
state = privacy.conversation_state(request.store, conversation["id"])
if state.get("forgotten") or state.get("memory_disabled"):
return "conversation_no_store"
if state.get("topics"):
return "sensitive_topic"
if body.get("no_store") is True or body.get("sensitivity") in {"sensitive", "restricted"}:
return "client_no_store"
return None
def _suggestion(context: str, surface: str) -> dict[str, Any]:
base = CATALOG[context]
record = {
"schema": "hux.suggestion.v1", **base,
"trigger": {"surface": surface, "context": context},
"suppression": {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True},
}
problems = contracts.validate("suggestion.schema.json", record, SCHEMAS, "/$defs/suggestion")
if problems:
raise Invalid("suggestion failed contract validation", problems)
return record
def _state_id(suggestion_id: str, project_id: str, conversation_id: str) -> str:
value = f"{suggestion_id}:{project_id}:{conversation_id}".encode()
return f"sugs_{hashlib.sha256(value).hexdigest()[:28]}"
def _public(state: dict[str, Any]) -> dict[str, Any]:
result = {key: state[key] for key in ("schema", "owner", "suggestion_id", "shows", "never_again")}
for key in ("last_shown_at", "dismissed_at", "acted_at"):
if key in state:
result[key] = state[key]
problems = contracts.validate("suggestion.schema.json", result, SCHEMAS, "/$defs/state")
if problems:
raise Invalid("suggestion state failed contract validation", problems)
return result
def _eligible(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> str | None:
if state is None:
return None
if state.get("never_again"):
return "never_again"
rules = suggestion["suppression"]
if state.get("shows", 0) >= rules["max_shows"]:
return "max_shows"
last = state.get("last_shown_at")
if last and now - datetime.fromisoformat(last.replace("Z", "+00:00")) < timedelta(seconds=rules["cooldown_seconds"]):
return "cooldown"
return None
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(json.dumps({"body": body, "path": path}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different suggestion action")
return row["result"]
return None
def evaluate(request: Request) -> Response:
"""Evaluate and atomically count one eligible suggestion display."""
body = _body(request, {"context", "no_store", "sensitivity"})
project_id, conversation = _scope(request)
context = body.get("context")
if context not in CONTEXTS:
raise Invalid("unknown suggestion context")
gated = _privacy_gate(request, conversation, body)
if gated:
request.audit("suggestions.evaluate", conversation["id"], reason=gated)
return Response(200, {"suggestion": None, "reason": gated, "stored": False}, {"Cache-Control": "no-store"})
key, fingerprint = _key(request), _fingerprint(body, request.path)
suggestion = _suggestion(context, request.identity.surface)
state_id = _state_id(suggestion["id"], project_id, conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
state = request.store.get(FAMILY, state_id) if request.store.exists(FAMILY, state_id) else None
reason = _eligible(suggestion, state, clock())
if reason:
result = {"suggestion": None, "reason": reason, "stored": False}
else:
if state is None and request.store.count(FAMILY) >= MAX_STATES:
result = {"suggestion": None, "reason": "state_limit", "stored": False}
else:
stamp = now_iso()
record = {
"id": state_id, "schema": "hux.suggestion_state.v1", "owner": request.identity.subject,
"suggestion_id": suggestion["id"], "shows": (state or {}).get("shows", 0) + 1,
"last_shown_at": stamp, "never_again": False,
}
stored = request.store.put(FAMILY, record, expected_revision=state["revision"] if state else 0)
result = {"suggestion": suggestion, "state": _public(stored), "revision": stored["revision"], "stored": True}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": now_iso()})
request.audit("suggestions.evaluate", suggestion["id"], reason=result.get("reason", "shown"))
headers = {"Cache-Control": "no-store"}
if result.get("revision"):
headers["ETag"] = str(result["revision"])
return Response(200, result, headers)
def decide(request: Request) -> Response:
"""Record only an explicit user click, guarded by If-Match and idempotency."""
body = _body(request, {"decision", "clicked"})
_, conversation = _scope(request)
gated = _privacy_gate(request, conversation, body)
if gated:
raise NotFound("suggestion state not found")
if body.get("clicked") is not True or body.get("decision") not in {"dismissed", "acted", "never_again"}:
raise Invalid("an explicit clicked decision is required")
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
state_id = _state_id(request.params["suggestion_id"], request.params["project_id"], conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
if not request.store.exists(FAMILY, state_id):
raise NotFound("suggestion state not found")
current = request.store.get(FAMILY, state_id)
if current["revision"] != expected:
raise Conflict("suggestion state revision does not match If-Match")
stamp, decision = now_iso(), body["decision"]
updated = {**current}
if decision in {"dismissed", "never_again"}:
updated["dismissed_at"] = stamp
if decision == "acted":
updated["acted_at"] = stamp
if decision == "never_again":
updated["never_again"] = True
stored = request.store.put(FAMILY, updated, expected_revision=expected)
result = {"state": _public(stored), "revision": stored["revision"], "decision": decision}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": stamp})
request.audit("suggestions.decide", request.params["suggestion_id"], reason=decision)
return Response(200, result, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
def list_states(request: Request) -> Response:
"""Inspect suppression state for exactly one project/conversation scope."""
project_id, conversation = _scope(request)
if _privacy_gate(request, conversation, {}):
return Response(200, {"items": [], "next": None}, {"Cache-Control": "no-store"})
prefix = lambda row: row["id"] == _state_id(row["suggestion_id"], project_id, conversation["id"])
items = [_public(row) | {"revision": row["revision"]} for row in request.store.scan(FAMILY) if prefix(row)]
request.audit("suggestions.list", conversation["id"])
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-09 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/suggestions"
router.add("POST", base + "/evaluate", CARD, "suggestions.evaluate", evaluate)
router.add("POST", base + "/{suggestion_id}/decisions", CARD, "suggestions.decide", decide)
router.add("GET", base + "/states", CARD, "suggestions.list", list_states)

View File

@ -0,0 +1,478 @@
"""HUX-12 release follow-through evidence producer (companion process).
This package lives OUTSIDE the network-free ``hux`` service package (SO-29):
it is the only component allowed to open outbound connections. It may import
``hux.errors`` and ``hux.release_security``; ``hux`` never imports it.
A pure observation -> verdict core (``evidence_for``) binds every observation
to the release being advanced and the workload policy, returning the exact
evidence payload for the next legal transition or raising ``Invalid``. A thin
``Collector`` gathers observations from Jenkins, Harbor, the Kubernetes API
and the health URL over HTTPS with an injectable transport. The driver
(``advance``/``run_once``) posts at most one verified transition per pass with
If-Match and a deterministic Idempotency-Key; verdicts derive only from passed
observations and the injected clock, and nothing here logs the evidence key.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
from hux import release_security
from hux.errors import Invalid
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
TERMINAL = frozenset({"live_verified", "rolled_back"})
ROLLBACK_STATES = frozenset({"built", "verified", "deployed", "converged", "live_verified"})
COMMIT = re.compile(r"^[0-9a-f]{40}$")
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
SHA_ANY = re.compile(r"sha256:[0-9a-f]{64}")
TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
FLUX_REV = re.compile(r"^main@sha1:[0-9a-f]{40}$")
NAMESPACE = re.compile(r"^[a-z0-9-]{1,63}$")
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
DEFAULT_KUBE_API = "https://kubernetes.default.svc"
DEFAULT_KUBE_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token" # noqa: S105
MAX_RESPONSE_BYTES = 1024 * 1024
Opener = Callable[[urllib.request.Request, float, ssl.SSLContext | None], Any]
def _check(condition: Any, message: str) -> None:
"""Fail closed with a precise reason unless ``condition`` holds."""
if not condition:
raise Invalid(message)
def parse_time(value: Any, label: str) -> datetime:
"""Parse one strict RFC 3339 UTC second-precision timestamp."""
_check(isinstance(value, str) and TIME.fullmatch(value), f"{label} must be an RFC 3339 UTC timestamp")
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
def _fresh(value: Any, now: datetime, max_age: int, label: str) -> str:
age = (now - parse_time(value, label)).total_seconds()
_check(0 <= age <= max_age, f"stale evidence: {label} is outside the freshness window")
return str(value)
def _observation(observations: Mapping[str, Any], name: str, workload: str, now: datetime, max_age: int) -> dict[str, Any]:
obs = observations.get(name)
_check(isinstance(obs, dict), f"incomplete observations: {name} is missing")
_check(obs.get("workload") == workload, f"{name} observation is bound to a different workload")
_fresh(obs.get("observed_at"), now, max_age, f"{name}.observed_at")
return obs
def _need(obs: Mapping[str, Any], name: str, field: str, kind: type = str) -> Any:
value = obs.get(field)
_check(not isinstance(value, bool) and isinstance(value, kind), f"incomplete observations: {name}.{field} is missing or malformed")
return value
def _verify_merged(release, observations, wpolicy, max_age, now):
git = _observation(observations, "git", release["workload"], now, max_age)
commit = _need(git, "git", "commit")
_check(COMMIT.fullmatch(commit), "git commit must be an exact 40-hex SHA")
_check(parse_time(_need(git, "git", "committed_at"), "git.committed_at") <= now, "git commit timestamp is in the future")
review_url = _need(git, "git", "review_url")
_check(review_url == release.get("evidence", {}).get("review_url"), "git observation is for a different review than this release")
_check(review_url.startswith(wpolicy["review_url_prefix"]), "review URL is outside the policy review prefix")
return {"merge_commit": commit}
def _verify_built(release, observations, wpolicy, max_age, now):
jenkins = _observation(observations, "jenkins", release["workload"], now, max_age)
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
sha = str(release.get("evidence", {}).get("merge_commit", ""))
build_url, job = _need(jenkins, "jenkins", "build_url"), wpolicy["jenkins_job_url"]
_check(build_url == job or build_url.startswith(job + "/"), "Jenkins build URL is outside the policy job")
_check(_need(jenkins, "jenkins", "result") == "SUCCESS", "Jenkins build result is not SUCCESS")
_check(COMMIT.fullmatch(sha) and _need(jenkins, "jenkins", "revision") == sha, "Jenkins build revision does not equal the merged commit")
number = _need(jenkins, "jenkins", "build_number", int)
_check(number > 0, "Jenkins build number is invalid")
tag = f"git-{sha}-build-{number}-release"
_check(_need(harbor, "harbor", "tag") == tag, "Harbor tag does not bind the merged commit and Jenkins build")
_check(_need(harbor, "harbor", "repository") == wpolicy["image_repository"], "Harbor repository does not match the policy image repository")
digest = _need(harbor, "harbor", "digest")
_check(SHA.fullmatch(digest), "Harbor digest is malformed")
_check(_need(jenkins, "jenkins", "image_digest") == digest, "Harbor digest does not equal the advertised image digest")
ref = f"{wpolicy['image_repository']}:{tag}@{digest}"
return {"ci_build_url": build_url, "image_ref": ref, "image_digest": digest, "harbor_digest": digest}
def _verify_verified(release, observations, wpolicy, max_age, now):
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
evidence = release.get("evidence", {})
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
same = (_need(harbor, "harbor", "repository") == wpolicy["image_repository"]
and tag and _need(harbor, "harbor", "tag") == tag
and _need(harbor, "harbor", "digest") == evidence.get("image_digest"))
_check(same, "Harbor no longer serves the built tag at the recorded digest")
return {}
def _verify_deployed(release, observations, wpolicy, max_age, now):
flux = _observation(observations, "flux", release["workload"], now, max_age)
_check(_need(flux, "flux", "name") == wpolicy["flux_kustomization"], "Flux kustomization name does not match policy")
revision = _need(flux, "flux", "applied_revision")
_check(FLUX_REV.fullmatch(revision), "Flux applied revision must look like main@sha1:<40-hex>")
_check(flux.get("pin_in_revision") is True, "manifest pin is not proven to be contained in the applied revision")
return {"flux_revision": revision}
def _verify_converged(release, observations, wpolicy, max_age, now):
spec = _observation(observations, "workload_spec", release["workload"], now, max_age)
pods = _observation(observations, "pods", release["workload"], now, max_age)
evidence = release.get("evidence", {})
ref, digest = str(evidence.get("image_ref", "")), str(evidence.get("image_digest", ""))
_check(ref and _need(spec, "workload_spec", "image") == ref, "desired workload image does not equal the built release reference")
entries = _need(pods, "pods", "pods", list)
_check(entries, "no Ready pods were observed for the workload")
for pod in entries:
_check(isinstance(pod, dict) and pod.get("ready") is True, "a workload pod is not Ready")
image_id = pod.get("image_id")
_check(isinstance(image_id, str) and image_id.endswith("@" + digest), "a Ready pod is not running the release digest")
return {"pod_digest": digest}
def _verify_live(release, observations, wpolicy, max_age, now):
health = _observation(observations, "health", release["workload"], now, max_age)
_check(_need(health, "health", "url") == wpolicy["health_url"], "health receipt is not for the policy health URL")
status = _need(health, "health", "status", int)
_check(status == 200, f"health check returned HTTP {status}, not 200")
checked = _fresh(health.get("checked_at"), now, max_age, "health.checked_at")
return {"health_check": {"url": health["url"], "status": "pass", "at": checked}}
def _verify_rollback(release, observations, wpolicy, max_age, now):
rollback = _observation(observations, "rollback", release["workload"], now, max_age)
digest = _need(rollback, "rollback", "digest")
_check(SHA.fullmatch(digest), "rollback target digest is malformed")
return {"rollback_target": digest}
_VERIFIERS = {
"merged": _verify_merged, "built": _verify_built, "verified": _verify_verified, "deployed": _verify_deployed,
"converged": _verify_converged, "live_verified": _verify_live, "rolled_back": _verify_rollback,
}
def next_target(state: str) -> str | None:
"""The only legal forward transition from ``state``; None when terminal."""
if state in TERMINAL:
return None
_check(state in ORDER, "release is in an unknown state")
return ORDER[ORDER.index(state) + 1]
def evidence_for(release: Mapping[str, Any], target: str, observations: Mapping[str, Any], wpolicy: Mapping[str, Any], max_age: int, now: datetime) -> dict[str, Any]:
"""Verify observations for exactly one legal transition or raise Invalid."""
_check(now.tzinfo is not None, "now must be a timezone-aware UTC datetime")
state = str(release.get("state", ""))
if target == "rolled_back":
_check(state in ROLLBACK_STATES, "there is no deployed image to roll back")
else:
_check(target == next_target(state), f"transition to {target} would skip, repeat or downgrade from {state}")
return _VERIFIERS[target](dict(release), observations, wpolicy, int(max_age), now)
def _default_opener(request: urllib.request.Request, timeout: float, context: ssl.SSLContext | None):
return urllib.request.urlopen(request, timeout=timeout, context=context) # noqa: S310
def _context(ca_file: str) -> ssl.SSLContext:
"""A verifying TLS context, trusting the given CA bundle when one is mounted."""
try:
return ssl.create_default_context(cafile=ca_file or None)
except (OSError, ssl.SSLError) as error:
raise Invalid("collector CA bundle is unavailable") from error
def _bounded_timeout(environ: Mapping[str, str]) -> float:
try:
value = float(environ.get("HUX_PRODUCER_TIMEOUT_SECONDS", 10.0))
except (TypeError, ValueError):
return 10.0
return value if 0.1 <= value <= 60.0 else 10.0
class Collector:
"""Observation gatherer; every field is observed via the transport, never asserted."""
def __init__(self, environ: Mapping[str, str], workload: str, wpolicy: Mapping[str, Any], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> None:
self.environ = dict(environ)
self.workload = workload
self.wpolicy = dict(wpolicy)
self.opener = opener or _default_opener
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.timeout = _bounded_timeout(environ)
def _stamp(self, fields: dict[str, Any]) -> dict[str, Any]:
observed = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
return {"workload": self.workload, "observed_at": observed, **fields}
def _fetch(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> tuple[int, bytes]:
_check(urlsplit(url).scheme == "https", f"{label} observation URL must be HTTPS")
request = urllib.request.Request(url, headers=dict(headers or {}), method="GET") # noqa: S310
try:
with self.opener(request, self.timeout, context or _context("")) as response:
status, data = int(response.status), response.read(MAX_RESPONSE_BYTES + 1)
except urllib.error.HTTPError as error:
return int(error.code), b""
except (OSError, ValueError) as error:
raise Invalid(f"{label} observation fetch failed") from error
_check(len(data) <= MAX_RESPONSE_BYTES, f"{label} observation response is too large")
return status, data
def _json(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> dict[str, Any]:
status, data = self._fetch(url, label, headers, context)
_check(status == 200, f"{label} observation returned HTTP {status}")
try:
parsed = json.loads(data)
except ValueError as error:
raise Invalid(f"{label} observation is not JSON") from error
_check(isinstance(parsed, dict), f"{label} observation is not a JSON object")
return parsed
def git(self, review_url: str) -> dict[str, Any]:
"""Observe the merged proposal named by the release's recorded review URL."""
prefix = self.wpolicy["review_url_prefix"]
number = review_url[len(prefix):] if review_url.startswith(prefix) else ""
_check(number.isdigit(), "release review URL is outside the policy review prefix")
parsed = urlsplit(prefix)
parts = parsed.path.strip("/").split("/")
_check(len(parts) == 3 and parts[2] == "pulls", "policy review prefix is not a proposal URL")
body = self._json(f"https://{parsed.netloc}/api/v1/repos/{parts[0]}/{parts[1]}/pulls/{number}", "git")
fields: dict[str, Any] = {"review_url": review_url}
commit, merged_at = body.get("merge_commit_sha"), body.get("merged_at")
if body.get("merged") is True and isinstance(commit, str):
fields["commit"] = commit.lower()
if isinstance(merged_at, str) and TIME.fullmatch(merged_at):
fields["committed_at"] = merged_at
return self._stamp(fields)
def jenkins(self) -> dict[str, Any]:
"""Observe the last successful build of the policy job."""
body = self._json(self.wpolicy["jenkins_job_url"] + "/lastSuccessfulBuild/api/json", "jenkins")
fields: dict[str, Any] = {}
url, result, number = body.get("url"), body.get("result"), body.get("number")
if isinstance(url, str):
fields["build_url"] = url.rstrip("/")
if isinstance(result, str):
fields["result"] = result
if isinstance(number, int) and not isinstance(number, bool):
fields["build_number"] = number
for action in body.get("actions") or []:
revision = action.get("lastBuiltRevision") if isinstance(action, dict) else None
sha = revision.get("SHA1") if isinstance(revision, dict) else None
if isinstance(sha, str) and COMMIT.fullmatch(sha.lower()):
fields["revision"] = sha.lower()
break
advertised = SHA_ANY.search(str(body.get("description") or ""))
if advertised:
fields["image_digest"] = advertised.group(0)
return self._stamp(fields)
def harbor(self, tag: str) -> dict[str, Any]:
"""Observe the immutable Harbor artifact behind one release tag."""
repository = self.wpolicy["image_repository"]
host, _, path = repository.partition("/")
project, _, name = path.partition("/")
_check(host and project and name, "policy image repository is not host/project/name")
body = self._json(f"https://{host}/api/v2.0/projects/{project}/repositories/{quote(name, safe='')}/artifacts/{quote(tag, safe='')}", "harbor")
fields: dict[str, Any] = {"repository": repository}
digest = body.get("digest")
if isinstance(digest, str) and SHA.fullmatch(digest):
fields["digest"] = digest
names = [entry.get("name") for entry in body.get("tags") or [] if isinstance(entry, dict)]
if tag in names:
fields["tag"] = tag
return self._stamp(fields)
def _namespace(self) -> str:
namespace = self.environ.get("HUX_PRODUCER_NAMESPACE", "")
_check(NAMESPACE.fullmatch(namespace), "HUX_PRODUCER_NAMESPACE is missing or malformed")
return namespace
def _kube(self, path: str) -> dict[str, Any]:
base = self.environ.get("HUX_PRODUCER_KUBE_API", DEFAULT_KUBE_API).rstrip("/")
token_file = self.environ.get("HUX_PRODUCER_KUBE_TOKEN_FILE", DEFAULT_KUBE_TOKEN)
try:
token = Path(token_file).read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError) as error:
raise Invalid("kubernetes credentials are unavailable") from error
_check(token, "kubernetes credentials are unavailable")
context = _context(self.environ.get("HUX_PRODUCER_KUBE_CA_FILE", ""))
return self._json(base + path, "kubernetes", {"Authorization": "Bearer " + token}, context)
def _workload_image(self) -> str | None:
kind = self.environ.get("HUX_PRODUCER_WORKLOAD_KIND", "statefulset").lower()
plural = {"deployment": "deployments", "statefulset": "statefulsets"}.get(kind)
_check(plural, "HUX_PRODUCER_WORKLOAD_KIND must be deployment or statefulset")
name = self.environ.get("HUX_PRODUCER_WORKLOAD_NAME", self.workload)
body = self._kube(f"/apis/apps/v1/namespaces/{self._namespace()}/{plural}/{name}")
containers = (((body.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
prefix = self.wpolicy["image_repository"] + ":"
for container in containers:
image = container.get("image") if isinstance(container, dict) else None
if isinstance(image, str) and image.startswith(prefix):
return image
return None
def workload_spec(self) -> dict[str, Any]:
"""Observe the live desired image of the policy workload."""
image = self._workload_image()
return self._stamp({"image": image} if image is not None else {})
def flux(self, expected_image: str) -> dict[str, Any]:
"""Observe the policy Kustomization; pin containment is proven, not asserted."""
name = self.wpolicy["flux_kustomization"]
body = self._kube(f"/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/{self._namespace()}/kustomizations/{name}")
status = body.get("status") or {}
applied = status.get("lastAppliedRevision")
fields: dict[str, Any] = {"name": (body.get("metadata") or {}).get("name")}
if isinstance(applied, str):
fields["applied_revision"] = applied
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
settled = isinstance(applied, str) and status.get("lastAttemptedRevision") in (None, applied)
pinned = bool(expected_image) and self._workload_image() == expected_image
fields["pin_in_revision"] = bool(ready and settled and pinned)
return self._stamp(fields)
def pods(self) -> dict[str, Any]:
"""Observe readiness and running imageID for every selected pod."""
selector = self.environ.get("HUX_PRODUCER_POD_SELECTOR", "")
_check(selector, "HUX_PRODUCER_POD_SELECTOR is required to observe pods")
body = self._kube(f"/api/v1/namespaces/{self._namespace()}/pods?labelSelector={quote(selector, safe='=,')}")
prefix = self.wpolicy["image_repository"] + ":"
entries = []
for item in body.get("items") or []:
if not isinstance(item, dict):
continue
status = item.get("status") or {}
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
image_id = None
for container in status.get("containerStatuses") or []:
if isinstance(container, dict) and str(container.get("image", "")).startswith(prefix):
image_id = container.get("imageID")
break
entries.append({"name": (item.get("metadata") or {}).get("name"), "ready": ready, "image_id": image_id})
return self._stamp({"pods": entries})
def health(self) -> dict[str, Any]:
"""Observe the policy health URL; the receipt carries the real HTTP status."""
url = self.wpolicy["health_url"]
checked = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
status, _ = self._fetch(url, "health")
return self._stamp({"url": url, "status": status, "checked_at": checked})
def rollback_target(self) -> dict[str, Any]:
"""Observe the digest currently pinned in the live workload spec."""
image = self._workload_image()
fields: dict[str, Any] = {}
digest = image.rpartition("@")[2] if isinstance(image, str) else ""
if SHA.fullmatch(digest):
fields["digest"] = digest
return self._stamp(fields)
def collect_for(collector: Collector, target: str, release: Mapping[str, Any]) -> dict[str, Any]:
"""Gather exactly the observations the verifier needs for one target state."""
evidence = release.get("evidence", {})
if target == "merged":
return {"git": collector.git(str(evidence.get("review_url", "")))}
if target == "built":
jenkins = collector.jenkins()
observations = {"jenkins": jenkins}
sha, number = jenkins.get("revision"), jenkins.get("build_number")
if sha is not None and number is not None:
observations["harbor"] = collector.harbor(f"git-{sha}-build-{number}-release")
return observations
if target == "verified":
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
return {"harbor": collector.harbor(tag)}
if target == "deployed":
return {"flux": collector.flux(str(evidence.get("image_ref", "")))}
if target == "converged":
return {"workload_spec": collector.workload_spec(), "pods": collector.pods()}
if target == "live_verified":
return {"health": collector.health()}
_check(target == "rolled_back", "no collector plan exists for the target state")
return {"rollback": collector.rollback_target()}
class ProducerClient:
"""Loopback HUX API client for the evidence identity; the key never leaves it."""
def __init__(self, base_url: str, slot: str, subject: str, key: str, opener: Opener | None = None, timeout: float = 10.0) -> None:
base = str(base_url or DEFAULT_BASE_URL).rstrip("/")
parts = urlsplit(base)
loopback = parts.scheme == "http" and parts.hostname in {"127.0.0.1", "::1", "localhost"}
_check(parts.scheme == "https" or loopback, "HUX base URL must be loopback HTTP or HTTPS")
self.base = base
self.opener = opener or _default_opener
self.timeout = timeout
self._headers = {
"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject, "X-Hux-Surface": "api",
"X-Hux-Trust": "evidence", "X-Hux-Relay-Key": key, "Content-Type": "application/json",
}
def request(self, method: str, path: str, body: Any = None, headers: Mapping[str, str] | None = None) -> tuple[int, Any, bool]:
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request( # noqa: S310
self.base + path, data=data, headers={**self._headers, **(headers or {})}, method=method)
try:
response = self.opener(request, self.timeout, None)
except urllib.error.HTTPError as error:
response = error
except OSError as error:
raise Invalid("HUX service is unreachable") from error
with response:
status = int(response.status)
raw = response.read(MAX_RESPONSE_BYTES)
replayed = str(response.headers.get("HUX-Replayed") or "") == "true"
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = None
return status, parsed, replayed
def advance(client: ProducerClient, collector: Collector, wpolicy: Mapping[str, Any], max_age: int, scope_path: str, release_id: str, now: datetime) -> dict[str, Any]:
"""Read the head, verify observations, and post at most one transition."""
status, view, _ = client.request("GET", f"{scope_path}/{release_id}")
_check(status == 200 and isinstance(view, dict), f"release read failed with HTTP {status}")
release, revision = view.get("release") or {}, view.get("revision")
_check(not isinstance(revision, bool) and isinstance(revision, int), "release view carries no revision")
target = next_target(str(release.get("state", "")))
if target is None:
return {"changed": False, "release_id": release_id, "state": release.get("state"), "reason": "release is terminal"}
observations = collect_for(collector, target, release)
evidence = evidence_for(release, target, observations, wpolicy, max_age, now)
status, view, replayed = client.request(
"POST", f"{scope_path}/{release_id}/transitions", {"to": target, "evidence": evidence},
{"If-Match": str(revision), "Idempotency-Key": f"producer-{release_id}-{target}-{revision}"[:120]})
_check(status == 200 and isinstance(view, dict), f"release transition was rejected with HTTP {status}")
head = view.get("release") or {}
return {"changed": not replayed, "release_id": release_id, "state": head.get("state"),
"revision": view.get("revision"), "replayed": replayed}
def _require(environ: Mapping[str, str], name: str) -> str:
value = str(environ.get(name, "")).strip()
_check(value, f"{name} is required")
return value
def _pick_release(client: ProducerClient, scope_path: str, workload: str) -> str | None:
status, listing, _ = client.request("GET", scope_path)
_check(status == 200 and isinstance(listing, dict), f"release list failed with HTTP {status}")
for item in listing.get("items") or []:
release = (item.get("release") or {}) if isinstance(item, dict) else {}
if release.get("workload") == workload and release.get("state") not in TERMINAL:
return str(release.get("id"))
return None
def run_once(environ: Mapping[str, str], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> dict[str, Any]:
"""One idempotent producer pass wired entirely from the environment."""
environ = dict(environ)
workload = _require(environ, "HUX_PRODUCER_WORKLOAD")
policy = release_security.load_policy(environ)
wpolicy = policy["workloads"].get(workload)
_check(wpolicy is not None, "workload is not enabled for release evidence")
key = release_security.evidence_key(environ)
client = ProducerClient(environ.get("HUX_BASE_URL", DEFAULT_BASE_URL), _require(environ, "HUX_TENANT_SLOT"),
_require(environ, "HUX_PRODUCER_SUBJECT"), key, opener, _bounded_timeout(environ))
project = _require(environ, "HUX_PRODUCER_PROJECT_ID")
conversation = _require(environ, "HUX_PRODUCER_CONVERSATION_ID")
scope_path = f"/hux/v1/projects/{project}/conversations/{conversation}/releases"
collector = Collector(environ, workload, wpolicy, opener, clock)
release_id = str(environ.get("HUX_PRODUCER_RELEASE_ID", "")).strip() or _pick_release(client, scope_path, workload)
if release_id is None:
return {"changed": False, "reason": "no advanceable release"}
return advance(client, collector, wpolicy, policy["max_evidence_age_seconds"], scope_path, release_id, collector.clock())

View File

@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Install the pinned, same-origin HUX BFF into Hermes WebUI."""
from __future__ import annotations
import hashlib
import hmac
import http.client
import json
import os
from pathlib import Path
import re
import socket
import stat
import time
from urllib.parse import parse_qsl, urlencode
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
UPSTREAM_HOST = "127.0.0.1"
UPSTREAM_PORT = 8790
RELAY_KEY_FILE = Path("/run/hermes-webui-hux/relay-key")
CONTEXT_KEY_FILE = Path("/run/hermes-hux-context/context-key")
MAX_REQUEST_BYTES = 1024 * 1024
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
MAX_ARTIFACT_REQUEST_BYTES = 34 * 1024 * 1024
MAX_ARTIFACT_RESPONSE_BYTES = 52 * 1024 * 1024
MAX_STREAM_BYTES = 16 * 1024 * 1024
UPSTREAM_TIMEOUT = 10.0
STREAM_DEADLINE = 65.0
CHUNK_BYTES = 64 * 1024
SAFE_PATH = re.compile(r"^/hux/v1(?:/[A-Za-z0-9._:-]+)*$")
SAFE_QUERY_KEY = re.compile(r"^[A-Za-z0-9._:@+-]{1,120}$")
SAFE_QUERY_VALUE = re.compile(r"^[A-Za-z0-9 ._:@+,-]{0,240}$")
SAFE_SLOT = re.compile(r"^slot-[0-9]{1,3}$")
SAFE_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$")
SAFE_KEY = re.compile(r"^[A-Za-z0-9._:-]{32,256}$")
SAFE_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,120}$")
SAFE_REVISION = re.compile(r"^[0-9]{1,10}$")
SAFE_EVENT_ID = re.compile(r"^[0-9]{1,20}$")
MUTATIONS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
CONTEXT_KEY_BYTES = 32
class BffError(Exception):
"""A bounded, user-safe proxy rejection."""
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
def _header_values(handler, name: str) -> list[str]:
headers = getattr(handler, "headers", None)
if headers is None:
return []
if hasattr(headers, "get_all"):
return [str(value) for value in (headers.get_all(name) or [])]
value = headers.get(name)
return [] if value is None else [str(value)]
def _one_header(handler, name: str) -> str:
values = _header_values(handler, name)
if len(values) > 1:
raise BffError(400, f"Duplicate {name} header")
return values[0].strip() if values else ""
def _trusted_identity(handler) -> tuple[str, str, str]:
from api.auth import ensure_trusted_auth_session, parse_cookie, verify_session
cookie = parse_cookie(handler)
if not cookie or not verify_session(cookie):
raise BffError(401, "Trusted WebUI session required")
info = ensure_trusted_auth_session(handler)
slot = _one_header(handler, "X-Hermes-Tenant-Identity")
supplied_subject = _one_header(handler, "X-Hux-Subject")
username = str((info or {}).get("username") or "")
if (not info or info.get("auth_type") != "trusted" or not SAFE_SLOT.fullmatch(slot)
or username != slot):
raise BffError(401, "Trusted WebUI session required")
subject = _derived_subject(slot)
if supplied_subject and supplied_subject != subject:
raise BffError(401, "Trusted WebUI session required")
return slot, subject, "chat"
def _context_key_path() -> Path:
raw = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip()
path = Path(raw) if raw else CONTEXT_KEY_FILE
if not path.is_absolute() or ".." in path.parts:
raise BffError(503, "HUX identity is unavailable")
return path
def _secure_context_key() -> bytes:
path = _context_key_path()
try:
parent = path.parent.stat(follow_symlinks=False)
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid():
raise BffError(503, "HUX identity is unavailable")
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) |
getattr(os, "O_NOFOLLOW", 0))
try:
info = os.fstat(descriptor)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or
stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1 or
info.st_size != CONTEXT_KEY_BYTES):
raise BffError(503, "HUX identity is unavailable")
key = os.read(descriptor, CONTEXT_KEY_BYTES + 1)
finally:
os.close(descriptor)
except BffError:
raise
except OSError as exc:
raise BffError(503, "HUX identity is unavailable") from exc
if len(key) != CONTEXT_KEY_BYTES:
raise BffError(503, "HUX identity is unavailable")
return key
def _derived_subject(slot: str) -> str:
digest = hmac.new(_secure_context_key(), b"hux.subject.id.v1\0" + slot.encode("ascii"),
hashlib.sha256).hexdigest()
subject = "usr_" + digest
if not SAFE_SUBJECT.fullmatch(subject): # defensive parity with the public identity contract
raise BffError(503, "HUX identity is unavailable")
return subject
def _require_csrf(handler, cookie: str) -> None:
from api.auth import CSRF_HEADER_NAME, verify_csrf_token
supplied = _one_header(handler, CSRF_HEADER_NAME)
if not supplied or not verify_csrf_token(cookie, supplied):
raise BffError(403, "Valid WebUI CSRF token required")
def _secure_relay_key() -> str:
parent = RELAY_KEY_FILE.parent
try:
parent_stat = parent.stat(follow_symlinks=False)
except OSError as exc:
raise BffError(503, "HUX relay is unavailable") from exc
if not stat.S_ISDIR(parent_stat.st_mode) or parent_stat.st_uid != os.geteuid() or parent_stat.st_mode & 0o077:
raise BffError(503, "HUX relay is unavailable")
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(RELAY_KEY_FILE, flags)
try:
file_stat = os.fstat(descriptor)
if (not stat.S_ISREG(file_stat.st_mode) or file_stat.st_uid != os.geteuid() or
stat.S_IMODE(file_stat.st_mode) != 0o400 or file_stat.st_nlink != 1):
raise BffError(503, "HUX relay is unavailable")
raw = os.read(descriptor, 257)
finally:
os.close(descriptor)
except BffError:
raise
except OSError as exc:
raise BffError(503, "HUX relay is unavailable") from exc
try:
key = raw.decode("ascii").strip()
except UnicodeDecodeError as exc:
raise BffError(503, "HUX relay is unavailable") from exc
if len(raw) > 256 or not SAFE_KEY.fullmatch(key):
raise BffError(503, "HUX relay is unavailable")
return key
def _safe_target(parsed) -> str:
path = str(getattr(parsed, "path", "") or "")
segments = path.split("/")[3:]
if not SAFE_PATH.fullmatch(path) or any(segment in {".", ".."} for segment in segments):
raise BffError(400, "Invalid HUX path")
raw_query = str(getattr(parsed, "query", "") or "")
if len(raw_query) > 4096:
raise BffError(400, "Invalid HUX query")
try:
pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True, max_num_fields=64)
except ValueError as exc:
raise BffError(400, "Invalid HUX query") from exc
if len({key for key, _ in pairs}) != len(pairs) or any(
not SAFE_QUERY_KEY.fullmatch(key) or not SAFE_QUERY_VALUE.fullmatch(value)
for key, value in pairs
):
raise BffError(400, "Invalid HUX query")
query = urlencode(pairs)
return path + ("?" + query if query else "")
def _request_limit(target: str, method: str) -> int:
path = target.split("?", 1)[0]
artifact_write = re.fullmatch(
r"/hux/v1/artifacts(?:/[A-Za-z0-9._:-]+/versions)?", path
)
return MAX_ARTIFACT_REQUEST_BYTES if method == "POST" and artifact_write else MAX_REQUEST_BYTES
def _response_limit(target: str) -> int:
path = target.split("?", 1)[0]
artifact_read = re.fullmatch(
r"/hux/v1/artifacts/[A-Za-z0-9._:-]+/versions/[0-9]+(?:/diff)?", path
)
return MAX_ARTIFACT_RESPONSE_BYTES if artifact_read else MAX_RESPONSE_BYTES
def _read_request(handler, method: str, limit: int = MAX_REQUEST_BYTES) -> bytes | None:
if _one_header(handler, "Transfer-Encoding"):
raise BffError(400, "Chunked HUX requests are not supported")
raw_length = _one_header(handler, "Content-Length")
if not raw_length:
return None
if not raw_length.isdigit():
raise BffError(400, "Invalid Content-Length")
length = int(raw_length)
if length > limit:
raise BffError(413, "HUX request body is too large")
if method == "GET" and length:
raise BffError(400, "GET request body is not allowed")
if length and _one_header(handler, "Content-Type").lower() != "application/json":
raise BffError(415, "HUX mutations require application/json")
body = bytearray()
while len(body) < length:
chunk = handler.rfile.read(length - len(body))
if not chunk:
raise BffError(400, "Incomplete HUX request body")
body.extend(chunk)
return bytes(body)
def _validated_passthrough_headers(handler, method: str, body: bytes | None) -> dict[str, str]:
headers = {"Accept": "text/event-stream" if str(handler.path).split("?", 1)[0].endswith("/stream")
else "application/vnd.hermes.hux+json; version=1"}
validators = (("If-Match", SAFE_REVISION), ("Idempotency-Key", SAFE_IDEMPOTENCY),
("Last-Event-ID", SAFE_EVENT_ID))
for name, pattern in validators:
value = _one_header(handler, name)
if value:
if not pattern.fullmatch(value) or (name == "Last-Event-ID" and method != "GET"):
raise BffError(400, f"Invalid {name} header")
headers[name] = value
if body is not None:
headers["Content-Type"] = "application/json"
return headers
def _send_headers(handler, status: int, content_type: str, length: int | None = None) -> None:
handler.send_response(status)
handler.send_header("Content-Type", content_type)
if length is not None:
handler.send_header("Content-Length", str(length))
handler.send_header("Cache-Control", "no-store")
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("Referrer-Policy", "no-referrer")
def _send_error(handler, error: BffError) -> bool:
body = json.dumps({"error": str(error)}, separators=(",", ":")).encode("utf-8")
_send_headers(handler, error.status, "application/json", len(body))
handler.end_headers()
handler.wfile.write(body)
return True
def _response_type(response) -> str:
content_type = str(response.getheader("Content-Type") or "application/json").split(";", 1)[0].strip().lower()
if content_type not in {"application/json", "application/vnd.hermes.hux+json", "text/event-stream"}:
raise BffError(502, "HUX returned an unsupported response")
return content_type
def _relay_stream(handler, response, content_type: str) -> bool:
_send_headers(handler, response.status, content_type)
handler.send_header("Connection", "close")
handler.end_headers()
handler.close_connection = True
total = 0
deadline = time.monotonic() + STREAM_DEADLINE
while time.monotonic() < deadline:
chunk = response.read(min(CHUNK_BYTES, MAX_STREAM_BYTES - total + 1))
if not chunk:
break
total += len(chunk)
if total > MAX_STREAM_BYTES:
break
handler.wfile.write(chunk)
handler.wfile.flush()
return True
def _relay_response(handler, response, limit: int = MAX_RESPONSE_BYTES) -> bool:
if 300 <= response.status < 400:
raise BffError(502, "HUX redirects are refused")
content_type = _response_type(response)
if content_type == "text/event-stream":
return _relay_stream(handler, response, content_type)
body = response.read(limit + 1)
if len(body) > limit:
raise BffError(502, "HUX response is too large")
_send_headers(handler, response.status, content_type, len(body))
etag = response.getheader("ETag")
if etag and re.fullmatch(r'"?[0-9]{1,10}"?', etag):
handler.send_header("ETag", etag)
if str(response.getheader("HUX-Replayed") or "").lower() == "true":
handler.send_header("HUX-Replayed", "true")
audit_stale = str(response.getheader("HUX-Audit-Stale") or "").lower()
if audit_stale in {"true", "false"}:
handler.send_header("HUX-Audit-Stale", audit_stale)
if str(response.getheader("Content-Disposition") or "").lower() == "attachment":
handler.send_header("Content-Disposition", "attachment")
handler.end_headers()
handler.wfile.write(body)
return True
def proxy_hux(handler, parsed, method: str) -> bool:
"""Proxy one authenticated WebUI request to the fixed loopback HUX service."""
connection = None
try:
method = str(method or "").upper()
if method != "GET" and method not in MUTATIONS:
raise BffError(405, "HUX method is not allowed")
slot, subject, surface = _trusted_identity(handler)
from api.auth import parse_cookie
if method in MUTATIONS:
_require_csrf(handler, parse_cookie(handler) or "")
target = _safe_target(parsed)
body = _read_request(handler, method, _request_limit(target, method))
headers = _validated_passthrough_headers(handler, method, body)
headers.update({"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject,
"X-Hux-Surface": surface, "X-Hux-Trust": "relay",
"X-Hux-Relay-Key": _secure_relay_key()})
connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=UPSTREAM_TIMEOUT)
connection.request(method, target, body=body, headers=headers)
return _relay_response(handler, connection.getresponse(), _response_limit(target))
except BffError as error:
return _send_error(handler, error)
except (TimeoutError, socket.timeout, ConnectionError, OSError, http.client.HTTPException):
return _send_error(handler, BffError(502, "HUX service is unavailable"))
finally:
if connection is not None:
connection.close()
def _patched_routes(source: str) -> str:
if "from api.hux_bff import proxy_hux" in source:
raise SystemExit("Hermes HUX BFF patch is already installed")
replacements = []
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
before = (f"def handle_{method.lower()}(handler, parsed) -> bool:\n"
f" \"\"\"Handle all {method} routes. Returns True if handled, False for 404.\"\"\"\n")
after = before + (" if parsed.path == \"/hux/v1\" or parsed.path.startswith(\"/hux/v1/\"):\n"
" from api.hux_bff import proxy_hux\n"
f" return proxy_hux(handler, parsed, \"{method}\")\n")
if source.count(before) != 1:
raise SystemExit(f"Hermes HUX BFF patch context changed: {before[:80]!r}")
replacements.append((before, after))
for before, after in replacements:
source = source.replace(before, after, 1)
return source
def apply(root: Path | None = None) -> None:
"""Install this module and patch all five pinned WebUI route dispatchers."""
root = root or Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
api_dir = root / "api"
routes = api_dir / "routes.py"
installed = api_dir / "hux_bff.py"
patched = _patched_routes(routes.read_text(encoding="utf-8"))
installed.write_text(Path(__file__).read_text(encoding="utf-8"), encoding="utf-8")
routes.write_text(patched, encoding="utf-8")
if __name__ == "__main__":
apply()

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Install trusted HUX session context into the pinned Hermes WebUI routes."""
from __future__ import annotations
import os
from pathlib import Path
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
MODULE_SOURCE = Path(__file__).with_name("hermes-webui-hux-context.py")
def _patched_routes(source: str) -> str:
if "from api.hux_context import attach_hux_context" in source:
raise SystemExit("Hermes HUX context patch is already installed")
replacements = (
(
" redact = redact_session_data(raw)\n",
" redact = redact_session_data(raw)\n"
" from api.hux_context import attach_hux_context\n"
" redact = attach_hux_context(handler, redact)\n",
),
(
' return j(handler, {"session": redact_session_data(sess)})\n',
" from api.hux_context import attach_hux_context\n"
' return j(handler, {"session": attach_hux_context(\n'
" handler, redact_session_data(sess)\n"
" )})\n",
),
(
' payload = {"session": s.compact() | {"messages": s.messages}}\n',
" from api.hux_context import attach_hux_context\n"
' payload = {"session": attach_hux_context(\n'
' handler, s.compact() | {"messages": s.messages}\n'
" )}\n",
),
(
" return j(handler, response, status=status)\n",
" from api.hux_context import attach_hux_context\n"
" response = attach_hux_context(handler, response)\n"
" return j(handler, response, status=status)\n",
),
)
for before, after in replacements:
if source.count(before) != 1:
raise SystemExit(f"Hermes HUX context patch changed: {before.strip()!r}")
source = source.replace(before, after, 1)
return source
def apply(root: Path | None = None, module_source: Path | None = None) -> None:
"""Validate all pinned anchors, then install the module and route calls."""
root = root or ROOT
routes = root / "api/routes.py"
source_path = module_source or MODULE_SOURCE
module = source_path.read_text(encoding="utf-8")
if "def attach_hux_context(" not in module or "def build_hux_context(" not in module:
raise SystemExit("Hermes HUX context module is invalid")
patched = _patched_routes(routes.read_text(encoding="utf-8"))
(root / "api/hux_context.py").write_text(module, encoding="utf-8")
routes.write_text(patched, encoding="utf-8")
if __name__ == "__main__": # pragma: no cover - image entry point
apply()

View File

@ -0,0 +1,222 @@
"""Build browser HUX scope only from an authenticated server session."""
from __future__ import annotations
import hashlib
import hmac
import os
from pathlib import Path
import re
import secrets
import stat
import threading
from typing import Any
_RAW_ID = re.compile(r"^[A-Za-z0-9._:@+-]{1,200}$")
_SLOT = re.compile(r"^slot-[0-9]{1,3}$")
_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$")
_KEY_BYTES = 32
_KEY_NAME = ".hux-context-key"
_DEFAULT_PROJECT_SOURCE = "profile:default"
_ID_PURPOSES = {
"ses": "session",
"conv": "conversation",
"prj": "project",
"run": "run",
"msg": "message",
}
_KEY_LOCK = threading.Lock()
_KEY_CACHE: tuple[Path, bytes] | None = None
class HuxContextUnavailable(Exception):
"""Signal that trusted context cannot be produced for this response."""
def _safe_value(value: Any, fallback: str | None = None) -> str:
candidate = value if isinstance(value, str) else ""
if _RAW_ID.fullmatch(candidate):
return candidate
if fallback is not None:
return fallback
raise HuxContextUnavailable("WebUI session identity is unavailable")
def _key_path() -> Path:
from api.config import STATE_DIR
configured = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip()
path = Path(configured) if configured else Path(STATE_DIR) / _KEY_NAME
if not path.is_absolute() or ".." in path.parts:
raise HuxContextUnavailable("HUX context key path is invalid")
root = path.parent
try:
info = root.stat(follow_symlinks=False)
except OSError as exc:
raise HuxContextUnavailable("HUX context state is unavailable") from exc
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid():
raise HuxContextUnavailable("HUX context state is unavailable")
return path
def _read_key(path: Path) -> bytes:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
try:
info = os.fstat(descriptor)
if (
not stat.S_ISREG(info.st_mode)
or info.st_uid != os.geteuid()
or stat.S_IMODE(info.st_mode) != 0o600
or info.st_nlink != 1
or info.st_size != _KEY_BYTES
):
raise HuxContextUnavailable("HUX context key is unsafe")
value = os.read(descriptor, _KEY_BYTES + 1)
finally:
os.close(descriptor)
except HuxContextUnavailable:
raise
except OSError as exc:
raise HuxContextUnavailable("HUX context key is unavailable") from exc
if len(value) != _KEY_BYTES:
raise HuxContextUnavailable("HUX context key is invalid")
return value
def _create_key(path: Path) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
value = secrets.token_bytes(_KEY_BYTES)
try:
descriptor = os.open(path, flags, 0o600)
except FileExistsError:
return
except OSError as exc:
raise HuxContextUnavailable("HUX context key cannot be created") from exc
try:
written = os.write(descriptor, value)
if written != len(value):
raise HuxContextUnavailable("HUX context key write was incomplete")
os.fsync(descriptor)
finally:
os.close(descriptor)
def _context_key() -> bytes:
global _KEY_CACHE
path = _key_path()
with _KEY_LOCK:
if _KEY_CACHE and _KEY_CACHE[0] == path:
return _KEY_CACHE[1]
_create_key(path)
value = _read_key(path)
_KEY_CACHE = (path, value)
return value
def _project_source() -> str:
"""Return the bounded server-owned source for the default HUX project."""
configured = os.environ.get("HUX_PROJECT_SOURCE", _DEFAULT_PROJECT_SOURCE).strip()
return _safe_value(configured)
def _authoritative_scalar(value: Any) -> str | None:
"""Return one bounded scalar already present in a trusted server payload."""
if isinstance(value, bool):
return None
candidate = str(value) if isinstance(value, (str, int)) else ""
return candidate if _RAW_ID.fullmatch(candidate) else None
def _latest_message_source(session: dict[str, Any]) -> str | None:
"""Find the newest server-stamped message identifier without inventing one."""
messages = session.get("messages")
if not isinstance(messages, list):
return None
for message in reversed(messages):
if not isinstance(message, dict):
continue
for field in ("id", "message_id"):
candidate = _authoritative_scalar(message.get(field))
if candidate is not None:
return candidate
return None
def derive_hux_id(key: bytes, prefix: str, purpose: str, slot: str, subject: str, raw: str) -> str:
"""Derive one cross-surface opaque ID from the shared server key."""
if (
not isinstance(key, bytes)
or len(key) != _KEY_BYTES
or _ID_PURPOSES.get(prefix) != purpose
or not _SLOT.fullmatch(slot)
or not _SUBJECT.fullmatch(subject)
or not _RAW_ID.fullmatch(raw)
):
raise HuxContextUnavailable("HUX context key is invalid")
message = "\0".join(("hux.context.id.v1", purpose, slot, subject, raw))
digest = hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
return f"{prefix}_{digest}"
def build_hux_context(handler: Any, session: dict[str, Any]) -> dict[str, Any]:
"""Return the exact context for one already-authorized server session."""
from api.hux_bff import _trusted_identity
if not isinstance(session, dict):
raise HuxContextUnavailable("WebUI session payload is unavailable")
raw_session = _safe_value(session.get("session_id"))
slot, subject, surface = _trusted_identity(handler)
raw_project = _project_source()
key = _context_key()
context = {
"schema": "hux.webui_context.v1",
"webui_session_id": raw_session,
"session_id": derive_hux_id(key, "ses", "session", slot, subject, raw_session),
"conversation_id": derive_hux_id(
key, "conv", "conversation", slot, subject, raw_session
),
"project_id": derive_hux_id(key, "prj", "project", slot, subject, raw_project),
"project_source": raw_project,
"identity": {
"tenant_slot": slot,
"subject": subject,
"surface": surface,
"trust": "relay",
},
}
# These optional values exist only when the upstream server has already
# minted them. A stream id is deliberately never treated as an agent run.
optional_sources = {
"run_id": ("run", "run", _authoritative_scalar(session.get("turn_id"))),
"message_id": ("msg", "message", _latest_message_source(session)),
"branch_point_message_id": (
"msg",
"message",
_authoritative_scalar(session.get("branch_point_message_id")),
),
}
for field, (prefix, purpose, source) in optional_sources.items():
if source is not None:
context[field] = derive_hux_id(key, prefix, purpose, slot, subject, source)
notebook = session.get("notebook_id")
if isinstance(notebook, str) and re.fullmatch(r"nb_[A-Za-z0-9._-]{4,80}", notebook):
context["notebook_id"] = notebook
return context
def attach_hux_context(handler: Any, session: Any) -> Any:
"""Attach trusted context or return a clean, feature-disabled payload."""
if not isinstance(session, dict):
return session
clean = dict(session)
clean.pop("hux_context", None)
try:
clean["hux_context"] = build_hux_context(handler, clean)
except Exception:
# Chat remains available when auth, state, or the optional HUX path fails.
clean.pop("hux_context", None)
return clean

View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Install the inert HUX browser bundle into the pinned Hermes WebUI."""
from __future__ import annotations
import os
from pathlib import Path
PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98"
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
STYLES = (
"hux/foundation.css",
"hux/runtime/wave_a_activity_memory.css",
"hux/runtime/autonomy-privacy.css",
"hux/runtime/wave_b.css",
"hux/runtime/wave_c_multimodal_onboarding_release.css",
"hux/bootstrap.css",
)
SCRIPTS = (
"hux/foundation.js",
"hux/shell.js",
"hux/runtime/wave_a_contract.js",
"hux/runtime/wave_a_activity_memory.js",
"hux/runtime/autonomy-privacy.js",
"hux/runtime/wave_b_contract.js",
"hux/runtime/wave_b_projects_modes.js",
"hux/runtime/wave_b_artifacts_research.js",
"hux/runtime/wave_b_runtime.js",
"hux/runtime/wave_c_multimodal_onboarding_release.js",
"hux/bootstrap.js",
)
def _patched_index(source: str) -> str:
if "id=\"hermesHuxBootstrapStyles\"" in source or "static/hux/bootstrap.js" in source:
raise SystemExit("Hermes HUX browser patch is already installed")
if source.count("</head>") != 1 or source.count("</body>") != 1:
raise SystemExit("Hermes HUX index patch context changed")
if "const S={session:null" not in (ROOT / "static/ui.js").read_text(encoding="utf-8"):
raise SystemExit("Hermes HUX trusted session context changed")
links = "\n".join(
f'<link rel="stylesheet" href="static/{asset}?v=__WEBUI_VERSION__"'
+ (' id="hermesHuxBootstrapStyles">' if asset == "hux/bootstrap.css" else ">")
for asset in STYLES
)
scripts = "\n".join(
f'<script src="static/{asset}?v=__WEBUI_VERSION__" defer></script>'
for asset in SCRIPTS
)
return source.replace("</head>", f"{links}\n</head>", 1).replace(
"</body>", f"{scripts}\n</body>", 1
)
def _patched_worker(source: str) -> str:
if "'./static/hux/bootstrap.js' + VQ" in source:
raise SystemExit("Hermes HUX service-worker patch is already installed")
marker = " './static/style.css' + VQ,\n"
if source.count(marker) != 1:
raise SystemExit("Hermes HUX service-worker patch context changed")
entries = "".join(f" './static/{asset}' + VQ,\n" for asset in (*STYLES, *SCRIPTS))
return source.replace(marker, marker + entries, 1)
def _patched_cancel_stream(source: str) -> str:
"""Make the existing owner-bound cancel report only backend-confirmed cancellation."""
replacement = " return Boolean(respOk && respBody && respBody.ok===true && " \
"respBody.cancelled===true && respBody.stream_id===streamId);\n"
if replacement in source:
raise SystemExit("Hermes HUX cancel-result patch is already installed")
marker = " return respOk;\n}\n\nasync function cancelSessionStream"
if source.count(marker) != 1:
raise SystemExit("Hermes HUX cancel-result patch context changed")
return source.replace(marker, replacement + "}\n\nasync function cancelSessionStream", 1)
def _patched_start_context(source: str) -> str:
"""Accept only the context attached to the authoritative chat-start reply."""
marker = " const startData = postStartData || {};\n"
if "HermesHuxBootstrap.mergeTrustedContext" in source:
raise SystemExit("Hermes HUX chat-start patch is already installed")
if source.count(marker) != 1:
raise SystemExit("Hermes HUX chat-start patch context changed")
replacement = marker + (
" if(typeof HermesHuxBootstrap==='object' && S.session){\n"
" HermesHuxBootstrap.mergeTrustedContext(S.session, startData);\n"
" }\n"
)
return source.replace(marker, replacement, 1)
def apply(root: Path | None = None) -> None:
"""Patch the index and offline shell after validating every input."""
global ROOT
ROOT = root or ROOT
index = ROOT / "static/index.html"
worker = ROOT / "static/sw.js"
boot = ROOT / "static/boot.js"
messages = ROOT / "static/messages.js"
for asset in (*STYLES, *SCRIPTS):
if not (ROOT / "static" / asset).is_file():
raise SystemExit(f"Hermes HUX browser asset is missing: {asset}")
patched_index = _patched_index(index.read_text(encoding="utf-8"))
patched_worker = _patched_worker(worker.read_text(encoding="utf-8"))
patched_boot = _patched_cancel_stream(boot.read_text(encoding="utf-8"))
patched_messages = _patched_start_context(messages.read_text(encoding="utf-8"))
index.write_text(patched_index, encoding="utf-8")
worker.write_text(patched_worker, encoding="utf-8")
boot.write_text(patched_boot, encoding="utf-8")
messages.write_text(patched_messages, encoding="utf-8")
if __name__ == "__main__":
apply()

View File

@ -0,0 +1,68 @@
# HUX client foundation integration contract
The WebUI image packages these files. Its server derives a matching
`hux.webui_context.v1` only from the authenticated, persisted WebUI session,
the bounded server `HUX_PROJECT_SOURCE`, the trusted tenant slot, and the
router-asserted HUX subject. The browser bridge remains inert unless that
context is present and the same-origin capability endpoint advertises a
complete route set. It is versioned as `hux.v1`, contains no tenant-global
browser storage, and defaults to a hidden shell.
The router negotiation endpoint is `GET /hux/v1/capabilities`. A disabled
foundation returns 404. An enabled response is:
```json
{
"schema": "hux.capabilities.v1",
"contract_version": "1.1.0",
"identity": {
"tenant_slot": "slot-3",
"subject": "usr_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"surface": "chat",
"trust": "relay"
},
"cards": [
{
"card": "HUX-11",
"flag": "hux.foundation",
"enabled": true,
"routes": ["/hux/v1/capabilities", "/hux/v1/context/bootstrap"]
}
],
"server": {}
}
```
The boot bridge reads opaque identity and scope only from `S.session.hux_context`.
That record must bind its `webui_session_id` to the loaded WebUI session. The
bridge compares it to the authenticated BFF capability identity before it
mounts anything. It must never pass a raw Keycloak subject, email, Telegram
identifier, or tenant ordinal. The client rejects an identity or surface
mismatch, unknown versions, cross-user objects, and cross-conversation
records. It does not use local/session storage.
Opaque IDs use a server-owned 32-byte key at `HUX_CONTEXT_KEY_FILE`, or
`$HERMES_WEBUI_STATE_DIR/.hux-context-key` by default. The file is created
before context is emitted and must remain an owner-only regular file. Chat and
Worker derive the same IDs with `hux.context.id.v1`; no browser value can
select the subject, slot, key, or default project source. The project source
defaults to `profile:default` when its server environment override is absent.
After capability negotiation, the bridge sends the exact server-derived scope
to `POST /hux/v1/context/bootstrap` with CSRF and an idempotency key. The HUX
service recomputes every ID from the shared key and creates or verifies the
project/conversation binding. The drawer is not created unless that response
matches the authenticated context exactly.
Feature extensions register an id, label, HUX flag, display order, and render
callback with `HermesHuxShell.createShell()`. The shell is hidden for a 404,
for an off foundation, or when no registered extension is enabled. Loading and
errors are accessible status regions; errors disclose no backend body. Event
adapters omit `detail`, evidence URIs, hashes, and tool payloads. Object
adapters return display metadata only. Feature code must fetch sensitive
details from its owning tenant-scoped endpoint after an explicit user action.
Scripts load in dependency order and the image service worker caches the same
reviewed assets. No manifest or live HUX flag is changed by this browser
bundle. A 404, missing server context, incomplete route advertisement, or
session change destroys or suppresses the workspace.

View File

@ -0,0 +1,260 @@
/** Compact, accessible HUX-01 activity timeline. The feature is default-off. */
import {
Activity,
AlertTriangle,
Check,
GitBranch,
ListChecks,
LoaderCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
activityTimelineEnabled,
mergeActivityEvents,
normalizeCancellationReceipt,
surfacePolicy,
} from "./model.ts";
import type {
ActivityItem,
CancellationReceipt,
HuxSurface,
RawActivityEvent,
RawCancellationReceipt,
} from "./types.ts";
interface ActivityTimelineProps {
conversationId: string;
events: readonly RawActivityEvent[];
flags?: Iterable<string>;
surface: HuxSurface;
status?: "connecting" | "live" | "reconnecting" | "complete";
cancellationReceipts?: readonly RawCancellationReceipt[];
onRetry?: () => void;
}
const PHASE_LABELS: Readonly<Record<ActivityItem["phase"], string>> = {
intent: "Intent",
action: "Action",
evidence: "Evidence",
delegation: "Delegation",
decision: "Decision",
completion: "Complete",
failure: "Failed",
};
function PhaseIcon({ item }: { item: ActivityItem }) {
if (item.phase === "failure")
return <AlertTriangle className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "completion")
return <Check className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "delegation")
return <GitBranch className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "decision")
return <ListChecks className="h-3.5 w-3.5" aria-hidden />;
return <Activity className="h-3.5 w-3.5" aria-hidden />;
}
function TimelineRow({
item,
expanded,
}: {
item: ActivityItem;
expanded: boolean;
}) {
return (
<li
className={`hux-activity-row hux-tone-${item.tone}`}
data-kind={item.kind}
data-seq={item.seq}
>
<div className="hux-activity-marker">
<PhaseIcon item={item} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[0.6875rem] font-semibold uppercase tracking-[0.12em] text-current/65">
{PHASE_LABELS[item.phase]}
</span>
<time
className="text-[0.625rem] text-current/45"
dateTime={item.timestamp}
>
{new Date(item.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
})}
</time>
</div>
<p className="mt-0.5 text-xs leading-relaxed text-current/90">
{item.summary}
</p>
{expanded && item.evidence.length > 0 && (
<ul
className="mt-1.5 flex flex-wrap gap-1"
aria-label="Bounded evidence references"
>
{item.evidence.map((evidence, index) => (
<li
key={`${evidence.kind}-${index}`}
className="hux-evidence-chip"
>
{evidence.label}
</li>
))}
</ul>
)}
</div>
</li>
);
}
function CancellationCard({ receipt }: { receipt: CancellationReceipt }) {
const complete = receipt.outcome !== "failed_to_cancel";
return (
<aside
className="hux-cancellation-receipt"
aria-label="Cancellation receipt"
>
<div className="flex items-center gap-2 text-xs font-medium">
{complete ? (
<Check className="h-3.5 w-3.5" aria-hidden />
) : (
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
)}
<span>
{complete
? "Stop request handled"
: "Hermes could not confirm the stop"}
</span>
</div>
{receipt.sideEffects.length > 0 && (
<ul className="mt-1 list-inside list-disc text-[0.6875rem] text-current/70">
{receipt.sideEffects.map((effect, index) => (
<li key={index}>
{effect.description} —{" "}
{effect.reverted ? "reverted" : "left in place"}
</li>
))}
</ul>
)}
{receipt.omittedEffects > 0 && (
<p className="mt-1 text-[0.625rem] text-current/55">
{receipt.omittedEffects} additional side effect
{receipt.omittedEffects === 1 ? "" : "s"} recorded.
</p>
)}
</aside>
);
}
/** Render nothing unless both HUX-11 and HUX-01 have been explicitly enabled. */
export function ActivityTimeline({
conversationId,
events,
flags,
surface,
status = "live",
cancellationReceipts = [],
onRetry,
}: ActivityTimelineProps) {
const [showAll, setShowAll] = useState(false);
const enabled = activityTimelineEnabled(flags);
const policy = surfacePolicy(surface);
const result = useMemo(
() => mergeActivityEvents([], events, conversationId, surface),
[conversationId, events, surface],
);
const receipts = useMemo(
() =>
cancellationReceipts
.map((receipt) => normalizeCancellationReceipt(receipt, surface))
.filter((receipt): receipt is CancellationReceipt => receipt !== null)
.slice(-3),
[cancellationReceipts, surface],
);
if (!enabled) return null;
const hidden = Math.max(0, result.items.length - policy.initialItems);
const visible = showAll
? result.items
: result.items.slice(-policy.initialItems);
const reconnecting = status === "connecting" || status === "reconnecting";
return (
<section
className={`hux-activity hux-activity-${surface}`}
aria-label="Hermes activity timeline"
>
<header className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">What Hermes is doing</h2>
<p className="text-[0.6875rem] text-current/55">
{reconnecting
? "Reconnecting to durable activity…"
: `${result.items.length} recorded update${result.items.length === 1 ? "" : "s"}`}
</p>
</div>
{reconnecting && (
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
)}
</header>
{(result.conflicts > 0 || result.invalid > 0) && (
<div className="hux-activity-warning" role="status">
Some unsafe or conflicting activity was ignored. Visible history was
not rewritten.
</div>
)}
<ol
className="mt-3 space-y-1.5"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-busy={reconnecting}
>
{visible.map((item) => (
<TimelineRow
key={item.id}
item={item}
expanded={policy.expandRoutineEvents || !item.routine}
/>
))}
</ol>
{result.items.length === 0 && (
<p className="mt-3 text-xs text-current/55">
No activity has been recorded yet.
</p>
)}
{hidden > 0 && !showAll && (
<button
className="hux-activity-more"
type="button"
onClick={() => setShowAll(true)}
>
Show {hidden} earlier update{hidden === 1 ? "" : "s"}
</button>
)}
{showAll && hidden > 0 && (
<button
className="hux-activity-more"
type="button"
onClick={() => setShowAll(false)}
>
Show recent activity only
</button>
)}
{reconnecting && onRetry && (
<button className="hux-activity-more" type="button" onClick={onRetry}>
Retry now
</button>
)}
{receipts.map((receipt) => (
<CancellationCard key={receipt.id} receipt={receipt} />
))}
</section>
);
}

View File

@ -0,0 +1,16 @@
export { ActivityTimeline } from "./ActivityTimeline.tsx";
export {
ACTIVITY_TIMELINE_FLAG,
FOUNDATION_FLAG,
activityTimelineEnabled,
mergeActivityEvents,
normalizeCancellationReceipt,
surfacePolicy,
} from "./model.ts";
export type {
ActivityItem,
CancellationReceipt,
HuxSurface,
RawActivityEvent,
RawCancellationReceipt,
} from "./types.ts";

View File

@ -0,0 +1,306 @@
/** Normalization, replay safety, and surface policy for HUX-01. */
import {
isOpaqueId,
isRfc3339Utc,
safeEvidenceLabel,
safeText,
} from "./security.ts";
import type {
ActivityItem,
ActivityPhase,
ActivityTone,
CancellationReceipt,
HuxSurface,
MergeResult,
RawActivityEvent,
RawCancellationReceipt,
SurfacePolicy,
} from "./types.ts";
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
export const FOUNDATION_FLAG = "hux.foundation";
const EVENT_KINDS = new Set([
"message.user",
"message.assistant",
"decision.route",
"decision.plan",
"tool.call",
"tool.result",
"approval.requested",
"approval.resolved",
"memory.proposed",
"memory.committed",
"memory.forgotten",
"artifact.created",
"artifact.version",
"artifact.promoted",
"citation.attached",
"mode.changed",
"run.started",
"run.cancelled",
"run.completed",
"run.failed",
"privacy.notice",
"suggestion.shown",
"suggestion.dismissed",
"release.transition",
"delegation.started",
"delegation.completed",
"delegation.failed",
"memory.suppressed",
"memory.retrieval_removed",
"budget.exhausted",
"side_effect.blocked",
"side_effect.released",
]);
const SURFACE_POLICIES: Readonly<Record<HuxSurface, SurfacePolicy>> = {
chat: {
maxItems: 80,
initialItems: 12,
evidenceLimit: 3,
cancellationEffectLimit: 4,
expandRoutineEvents: false,
},
worker: {
maxItems: 250,
initialItems: 40,
evidenceLimit: 8,
cancellationEffectLimit: 12,
expandRoutineEvents: true,
},
telegram: {
maxItems: 40,
initialItems: 8,
evidenceLimit: 2,
cancellationEffectLimit: 3,
expandRoutineEvents: false,
},
voice: {
maxItems: 24,
initialItems: 6,
evidenceLimit: 1,
cancellationEffectLimit: 2,
expandRoutineEvents: false,
},
api: {
maxItems: 250,
initialItems: 40,
evidenceLimit: 8,
cancellationEffectLimit: 12,
expandRoutineEvents: true,
},
};
export function surfacePolicy(surface: HuxSurface): SurfacePolicy {
return { ...SURFACE_POLICIES[surface] };
}
export function activityTimelineEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(ACTIVITY_TIMELINE_FLAG);
}
function phaseFor(kind: string, hasParent: boolean): ActivityPhase {
if (kind === "message.user") return "intent";
if (
kind === "run.failed" ||
kind === "delegation.failed" ||
kind === "budget.exhausted"
) {
return "failure";
}
if (
kind === "run.completed" ||
kind === "run.cancelled" ||
kind === "message.assistant" ||
kind === "delegation.completed"
) {
return "completion";
}
if (kind === "delegation.started") return "delegation";
if (
kind.startsWith("decision.") ||
kind.startsWith("approval.") ||
kind === "mode.changed" ||
kind === "privacy.notice" ||
kind === "memory.suppressed" ||
kind === "memory.retrieval_removed" ||
kind === "side_effect.blocked"
)
return "decision";
if (
kind === "tool.result" ||
kind === "citation.attached" ||
kind === "artifact.version"
) {
return "evidence";
}
if (kind === "run.started" && hasParent) return "delegation";
return "action";
}
function toneFor(phase: ActivityPhase, kind: string): ActivityTone {
if (phase === "failure") return "warning";
if (phase === "completion") return "success";
if (kind === "approval.requested") return "warning";
if (phase === "action" || phase === "delegation") return "active";
return "neutral";
}
function hiddenSummary(kind: string): string {
if (kind === "tool.call") return "Hermes used a tool";
if (kind === "tool.result") return "Tool evidence was recorded";
if (kind === "run.failed")
return "The run failed; sensitive detail is hidden";
if (kind === "run.completed") return "The run completed";
return "Activity recorded; sensitive detail is hidden";
}
function normalizeEvent(
raw: RawActivityEvent,
conversationId: string,
policy: SurfacePolicy,
): ActivityItem | null {
if (raw.schema !== "hux.event.v1" || !isOpaqueId(raw.id, "evt")) return null;
if (!Number.isInteger(raw.seq) || (raw.seq as number) < 0) return null;
if (!isRfc3339Utc(raw.ts) || raw.conversation_id !== conversationId)
return null;
if (typeof raw.kind !== "string" || !EVENT_KINDS.has(raw.kind)) return null;
const redaction = raw.redaction as { level?: unknown } | null;
const fullyHidden =
raw.sensitivity === "restricted" || redaction?.level === "full";
const phase = phaseFor(raw.kind, typeof raw.parent_event_id === "string");
const references = Array.isArray(raw.evidence) ? raw.evidence : [];
const evidence = references
.map((item) => safeEvidenceLabel((item as { kind?: unknown } | null)?.kind))
.filter((item): item is NonNullable<typeof item> => item !== null)
.slice(0, policy.evidenceLimit);
return {
id: raw.id,
seq: raw.seq as number,
timestamp: raw.ts,
conversationId,
runId:
typeof raw.run_id === "string"
? safeText(raw.run_id, "", 120) || undefined
: undefined,
kind: raw.kind,
phase,
tone: toneFor(phase, raw.kind),
summary: fullyHidden
? hiddenSummary(raw.kind)
: safeText(raw.summary, hiddenSummary(raw.kind)),
evidence,
routine: phase === "action" || phase === "evidence",
};
}
/**
* Merge reconnect pages without duplicates. Existing sequence owners win;
* conflicting replay entries are rejected instead of rewriting visible history.
*/
export function mergeActivityEvents(
existing: readonly ActivityItem[],
incoming: readonly RawActivityEvent[],
conversationId: string,
surface: HuxSurface,
): MergeResult {
const policy = surfacePolicy(surface);
const byId = new Map(existing.map((item) => [item.id, item]));
const bySeq = new Map(existing.map((item) => [item.seq, item]));
let invalid = 0;
let replays = 0;
let conflicts = 0;
const normalized: ActivityItem[] = [];
for (const raw of incoming) {
const item = normalizeEvent(raw, conversationId, policy);
if (!item) {
invalid += 1;
continue;
}
normalized.push(item);
}
// A reconnect page is not required to preserve transport order. Sorting
// before conflict resolution makes an initial replay deterministic.
normalized.sort(
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
);
for (const item of normalized) {
const priorId = byId.get(item.id);
if (priorId) {
if (priorId.seq === item.seq) replays += 1;
else conflicts += 1;
continue;
}
if (bySeq.has(item.seq)) {
conflicts += 1;
continue;
}
byId.set(item.id, item);
bySeq.set(item.seq, item);
}
const ordered = [...byId.values()].sort(
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
);
const afterSeq = ordered.length ? ordered[ordered.length - 1].seq : -1;
const truncated = Math.max(0, ordered.length - policy.maxItems);
return {
items: truncated ? ordered.slice(-policy.maxItems) : ordered,
afterSeq,
invalid,
replays,
conflicts,
truncated,
};
}
export function normalizeCancellationReceipt(
raw: RawCancellationReceipt,
surface: HuxSurface,
): CancellationReceipt | null {
const outcomes = new Set([
"cancelled",
"already_complete",
"failed_to_cancel",
]);
if (raw.schema !== "hux.cancel_receipt.v1" || !isOpaqueId(raw.id, "rcpt"))
return null;
if (typeof raw.run_id !== "string" || !isRfc3339Utc(raw.requested_at))
return null;
if (typeof raw.outcome !== "string" || !outcomes.has(raw.outcome))
return null;
const policy = surfacePolicy(surface);
const effects = Array.isArray(raw.side_effects) ? raw.side_effects : [];
const sideEffects = effects
.slice(0, policy.cancellationEffectLimit)
.map((value) => {
const effect = value as {
description?: unknown;
reverted?: unknown;
} | null;
return {
description: safeText(
effect?.description,
"A side effect was recorded",
280,
),
reverted: effect?.reverted === true,
};
});
return {
id: raw.id,
runId: safeText(raw.run_id, "unknown run", 120),
requestedAt: raw.requested_at,
acknowledgedAt: isRfc3339Utc(raw.acknowledged_at)
? raw.acknowledged_at
: undefined,
completedAt: isRfc3339Utc(raw.completed_at) ? raw.completed_at : undefined,
outcome: raw.outcome as CancellationReceipt["outcome"],
sideEffects,
omittedEffects: Math.max(0, effects.length - sideEffects.length),
};
}

View File

@ -0,0 +1,76 @@
/** Display-only redaction. Raw event payloads must never cross this boundary. */
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const PEM_BLOCK = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
const AUTH_VALUE =
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
const CREDENTIAL_BLOB =
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
/** Return bounded plain text or a safe fallback; objects are never stringified. */
export function safeText(
value: unknown,
fallback: string,
maxLength = 280,
): string {
if (typeof value !== "string") return fallback;
const limit = Math.max(1, maxLength);
const redacted = value
.replace(PEM_BLOCK, "[redacted credential]")
.replace(AUTH_VALUE, "$1=[redacted]")
.replace(BEARER, "Bearer [redacted]")
.replace(JWT, "[redacted credential]")
.replace(URI_USERINFO, "$1[redacted]@")
.replace(CREDENTIAL_BLOB, "[redacted credential]")
.replace(CONTROL_CHARACTERS, " ")
.replace(/\s+/g, " ")
.trim();
if (!redacted) return fallback;
return redacted.length <= limit
? redacted
: limit === 1
? "…"
: `${redacted.slice(0, limit - 1).trimEnd()}…`;
}
const EVIDENCE_LABELS: Readonly<Record<string, string>> = {
message: "Message",
tool_call: "Tool call",
tool_result: "Tool result",
artifact_version: "Artifact version",
source: "Source",
passage: "Supporting passage",
memory: "Memory record",
approval: "Approval",
run: "Run",
url: "Website",
file: "File",
build: "Build",
flux: "Flux reconciliation",
pod: "Workload",
};
/** Evidence labels are deliberately detached from IDs, URIs, and payloads. */
export function safeEvidenceLabel(
kind: unknown,
): { kind: string; label: string } | null {
if (typeof kind !== "string" || !(kind in EVIDENCE_LABELS)) return null;
return { kind, label: EVIDENCE_LABELS[kind] };
}
export function isRfc3339Utc(value: unknown): value is string {
return (
typeof value === "string" &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
!Number.isNaN(Date.parse(value))
);
}
export function isOpaqueId(value: unknown, prefix?: string): value is string {
if (typeof value !== "string") return false;
if (!/^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/.test(value)) return false;
return !prefix || value.startsWith(`${prefix}_`);
}

View File

@ -0,0 +1,85 @@
/* HUX-01 is opt-in. These scoped styles do not alter the existing WebUI. */
.hux-activity {
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, Canvas 94%, #12394a 6%);
color: CanvasText;
padding: 0.875rem;
}
.hux-activity-chat {
max-height: 24rem;
overflow: auto;
}
.hux-activity-row {
display: flex;
gap: 0.625rem;
border-radius: 0.5rem;
padding: 0.5rem;
}
.hux-activity-row:hover,
.hux-activity-row:focus-within {
background: color-mix(in srgb, currentColor 5%, transparent);
}
.hux-activity-marker {
display: grid;
width: 1.5rem;
height: 1.5rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 9999px;
}
.hux-tone-active .hux-activity-marker {
color: #39c6df;
}
.hux-tone-success .hux-activity-marker {
color: #4ade80;
}
.hux-tone-warning .hux-activity-marker {
color: #f59e0b;
}
.hux-evidence-chip {
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 9999px;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
color: color-mix(in srgb, currentColor 65%, transparent);
}
.hux-activity-warning,
.hux-cancellation-receipt {
margin-top: 0.625rem;
border-radius: 0.5rem;
background: color-mix(in srgb, #f59e0b 10%, transparent);
padding: 0.5rem;
font-size: 0.6875rem;
}
.hux-activity-more {
margin-top: 0.625rem;
min-height: 2rem;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
color: color-mix(in srgb, currentColor 70%, transparent);
}
.hux-activity-more:hover,
.hux-activity-more:focus-visible {
background: color-mix(in srgb, currentColor 7%, transparent);
outline: 2px solid color-mix(in srgb, #39c6df 65%, transparent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.hux-activity * {
animation: none !important;
}
}

View File

@ -0,0 +1,94 @@
/** Safe frontend types for the opt-in HUX-01 activity timeline. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type ActivityPhase =
| "intent"
| "action"
| "evidence"
| "delegation"
| "decision"
| "completion"
| "failure";
export type ActivityTone = "neutral" | "active" | "success" | "warning";
export interface RawActivityEvent {
schema?: unknown;
id?: unknown;
seq?: unknown;
ts?: unknown;
conversation_id?: unknown;
run_id?: unknown;
parent_event_id?: unknown;
kind?: unknown;
summary?: unknown;
detail?: unknown;
evidence?: unknown;
provenance?: unknown;
sensitivity?: unknown;
redaction?: unknown;
}
export interface SafeEvidence {
kind: string;
label: string;
}
export interface ActivityItem {
id: string;
seq: number;
timestamp: string;
conversationId: string;
runId?: string;
kind: string;
phase: ActivityPhase;
tone: ActivityTone;
summary: string;
evidence: SafeEvidence[];
routine: boolean;
}
export interface SurfacePolicy {
maxItems: number;
initialItems: number;
evidenceLimit: number;
cancellationEffectLimit: number;
expandRoutineEvents: boolean;
}
export interface MergeResult {
items: ActivityItem[];
afterSeq: number;
invalid: number;
replays: number;
conflicts: number;
truncated: number;
}
export interface RawCancellationReceipt {
schema?: unknown;
id?: unknown;
run_id?: unknown;
requested_at?: unknown;
acknowledged_at?: unknown;
completed_at?: unknown;
outcome?: unknown;
side_effects?: unknown;
}
export interface CancellationEffect {
description: string;
reverted: boolean;
}
export interface CancellationReceipt {
id: string;
runId: string;
requestedAt: string;
acknowledgedAt?: string;
completedAt?: string;
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
sideEffects: CancellationEffect[];
omittedEffects: number;
}

View File

@ -0,0 +1,221 @@
/** Accessible HUX-04 artifact UI. This module never fetches or executes content. */
import {
Code2,
Download,
FileText,
GitCompare,
History,
Image as ImageIcon,
Pencil,
Share2,
Sparkles,
} from "lucide-react";
import { useMemo, useState } from "react";
import { artifactsEnabled, normalizeArtifactWorkspace, normalizePreview } from "./model.ts";
import type {
Artifact,
ArtifactAuthorization,
ArtifactMutationIntent,
ArtifactPreview,
ArtifactScope,
DiffIntent,
HuxIdentity,
PromoteIntent,
RawArtifactEnvelope,
RawPreviewPayload,
} from "./types.ts";
interface ArtifactWorkspaceProps {
flags?: Iterable<string>;
identity: HuxIdentity;
scope: ArtifactScope;
page: RawArtifactEnvelope;
previews?: readonly RawPreviewPayload[];
busy?: boolean;
error?: string | null;
onLoadPreview?: (intent: ArtifactMutationIntent & {version: number}) => void;
onContinueEdit?: (intent: ArtifactMutationIntent) => void;
onDiff?: (intent: DiffIntent) => void;
onAuthorize?: (action: "download" | "share", intent: ArtifactMutationIntent) => void;
onDownload?: (intent: ArtifactMutationIntent & {version: number}) => void;
onShare?: (intent: ArtifactMutationIntent & {version: number}) => void;
onPromote?: (intent: PromoteIntent) => void;
}
function intent(artifact: Artifact, scope: ArtifactScope): ArtifactMutationIntent {
return {artifactId: artifact.id, expectedVersion: artifact.currentVersion, scope};
}
function RendererIcon({artifact}: {artifact: Artifact}) {
if (artifact.renderer === "image") return <ImageIcon aria-hidden />;
if (artifact.renderer === "code" || artifact.renderer === "data") return <Code2 aria-hidden />;
return <FileText aria-hidden />;
}
function Preview({artifact, preview}: {artifact: Artifact; preview: ArtifactPreview | null}) {
if (!preview) return <div className="hux-artifact-preview-empty">
<RendererIcon artifact={artifact} />
<p>Preview not loaded. Content stays behind its verified content reference.</p>
</div>;
if (preview.renderer === "image") {
return <figure className="hux-artifact-image">
<img src={preview.blobUrl!} alt={`Preview of ${artifact.title}`} />
<figcaption>{preview.mime}; local authorized preview</figcaption>
</figure>;
}
if (preview.renderer === "audio") {
return <div className="hux-artifact-audio">
<audio src={preview.blobUrl!} controls preload="metadata">
Audio preview is unavailable in this browser.
</audio>
<span>{preview.mime}; local authorized preview</span>
</div>;
}
const className = `hux-artifact-${preview.renderer}`;
return <div className={className} aria-label={`${preview.renderer} preview`}>
{preview.renderer === "report" && <h3>Report preview</h3>}
<pre tabIndex={0}><code>{preview.text}</code></pre>
</div>;
}
function AuthorizationAction({action, state, busy, onAuthorize, onAction}: {
action: "download" | "share";
state: ArtifactAuthorization["download"];
busy: boolean;
onAuthorize: () => void;
onAction: () => void;
}) {
const label = action === "download" ? "Download" : "Share";
const Icon = action === "download" ? Download : Share2;
if (state === "authorized") return <button type="button" disabled={busy} onClick={onAction}>
<Icon aria-hidden /> {label}
</button>;
if (state === "requires_approval") return <button type="button" disabled={busy} onClick={onAuthorize}>
<Icon aria-hidden /> Request {label.toLowerCase()} approval
</button>;
return <button type="button" disabled aria-label={`${label} unavailable`}>
<Icon aria-hidden /> {label} unavailable
</button>;
}
function VersionHistory({artifact, selectedVersion, setSelectedVersion, busy, scope, onDiff}: {
artifact: Artifact;
selectedVersion: number;
setSelectedVersion: (version: number) => void;
busy: boolean;
scope: ArtifactScope;
onDiff?: ArtifactWorkspaceProps["onDiff"];
}) {
return <section className="hux-artifact-history" aria-labelledby="hux-artifact-history-title">
<h3 id="hux-artifact-history-title"><History aria-hidden /> Immutable version history</h3>
<ol>{[...artifact.versions].reverse().map((version) => <li key={version.version}
data-current={version.version === artifact.currentVersion}>
<button type="button" aria-pressed={selectedVersion === version.version}
onClick={() => setSelectedVersion(version.version)}>
<strong>Version {version.version}</strong>
<span>{version.note || `Created by ${version.createdBy}`}</span>
<time dateTime={version.createdAt}>{new Date(version.createdAt).toLocaleString()}</time>
</button>
{version.version > 1 && onDiff && <button className="hux-artifact-diff" type="button"
disabled={busy} onClick={() => onDiff({
...intent(artifact, scope), fromVersion: version.diffFrom || version.version - 1,
toVersion: version.version,
})} aria-label={`Compare version ${version.version}`}><GitCompare aria-hidden /> Compare</button>}
</li>)}</ol>
</section>;
}
/** Render nothing unless all negotiated HUX-04 dependencies are active. */
export function ArtifactWorkspace(props: ArtifactWorkspaceProps) {
const enabled = artifactsEnabled(props.flags);
const page = useMemo(() => enabled ?
normalizeArtifactWorkspace(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
if (!enabled || !page) return null;
const artifact = page.artifacts.find((item) => item.id === selectedId) || page.artifacts[0] || null;
const version = artifact ? Math.min(selectedVersion || artifact.currentVersion, artifact.currentVersion) : null;
const rawPreview = artifact && version ? props.previews?.find((item) =>
item.artifact_id === artifact.id && item.version === version) : undefined;
const preview = artifact && rawPreview ?
normalizePreview(rawPreview, artifact, props.identity, props.scope) : null;
const attachments = artifact ? page.attachments.find((item) => item.artifactId === artifact.id) : null;
const authorization = artifact ? page.authorizations.find((item) => item.artifactId === artifact.id) : null;
function selectArtifact(item: Artifact) {
setSelectedId(item.id);
setSelectedVersion(item.currentVersion);
props.onLoadPreview?.({...intent(item, props.scope), version: item.currentVersion});
}
function selectVersion(next: number) {
if (!artifact) return;
setSelectedVersion(next);
props.onLoadPreview?.({...intent(artifact, props.scope), version: next});
}
return <section className="hux-artifacts" aria-label="Artifact workspace" aria-busy={props.busy}>
<nav className="hux-artifact-list" aria-label="Artifacts">
<header><h2><Sparkles aria-hidden /> Artifacts</h2>
<p>Durable outputs from this conversation.</p></header>
<ul>{page.artifacts.map((item) => <li key={item.id}>
<button type="button" aria-current={artifact?.id === item.id ? "page" : undefined}
onClick={() => selectArtifact(item)}>
<RendererIcon artifact={item} />
<span><strong>{item.title}</strong><small>{item.renderer} · v{item.currentVersion}</small></span>
</button>
</li>)}</ul>
{!page.artifacts.length && <p className="hux-artifact-empty">No artifacts are attached.</p>}
</nav>
<main className="hux-artifact-main">
{props.error && <p className="hux-artifact-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-artifact-warning" role="status">
Some artifacts were withheld because their owner or workspace binding was invalid.
</p>}
{artifact && version ? <>
<header className="hux-artifact-heading">
<div><span>{artifact.renderer}</span><h2>{artifact.title}</h2>
<p>Version {version} · {artifact.sensitivity} · {artifact.language || artifact.type}</p></div>
<div className="hux-artifact-actions">
<button type="button" disabled={props.busy}
onClick={() => props.onContinueEdit?.(intent(artifact, props.scope))}>
<Pencil aria-hidden /> Continue editing
</button>
<AuthorizationAction action="download" state={authorization?.download || "unavailable"}
busy={Boolean(props.busy)}
onAuthorize={() => props.onAuthorize?.("download", intent(artifact, props.scope))}
onAction={() => props.onDownload?.({...intent(artifact, props.scope), version})} />
<AuthorizationAction action="share" state={authorization?.share || "unavailable"}
busy={Boolean(props.busy)}
onAuthorize={() => props.onAuthorize?.("share", intent(artifact, props.scope))}
onAction={() => props.onShare?.({...intent(artifact, props.scope), version})} />
</div>
</header>
<Preview artifact={artifact} preview={preview} />
<section className="hux-artifact-evidence" aria-label="Attached research evidence">
<div><strong>{attachments?.sourceCount || 0}</strong><span>Sources attached</span></div>
<div><strong>{attachments?.citationCount || 0}</strong><span>Citations attached</span></div>
</section>
<button className="hux-artifact-promote" type="button" disabled={props.busy ||
artifact.promotedVersion === version} onClick={() => props.onPromote?.({
...intent(artifact, props.scope), targetProjectId: props.scope.projectId, version,
})}>
{artifact.promotedVersion === version ? "Promoted to this project" : "Promote version to project"}
</button>
</> : <p className="hux-artifact-empty">Select an artifact to inspect it.</p>}
</main>
<aside className="hux-artifact-side" aria-label="Artifact versions">
{artifact && version ? <VersionHistory artifact={artifact} selectedVersion={version}
setSelectedVersion={selectVersion} busy={Boolean(props.busy)} scope={props.scope}
onDiff={props.onDiff} /> : <p>Version history will appear here.</p>}
</aside>
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Artifact workspace is updating" : `${page.artifacts.length} artifacts available`}
</p>
</section>;
}

View File

@ -0,0 +1,94 @@
/** Same-origin endpoint descriptions for a future HUX-04 backend adapter. */
import { artifactsEnabled } from "./model.ts";
import { isOpaqueId, normalizeIdentity, scopedPath } from "./security.ts";
import type {
ArtifactEndpoint,
ArtifactEndpointContract,
ArtifactScope,
HuxIdentity,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(method: ArtifactEndpoint["method"], path: string, responseSchema: string,
requiredBody?: readonly string[]): ArtifactEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
function versionNumber(value: number): string {
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Artifact version is invalid");
return String(value);
}
/**
* The server derives identity from the authenticated session and verifies the
* project/conversation path binding. Mutation bodies MUST carry
* expected_current_version and return 409 on stale state. Existing versions
* are never PATCHed or deleted; edits append a new content_ref-backed version.
*/
export function createArtifactEndpointContract(client: FoundationClient, scope: ArtifactScope,
flags?: Iterable<string>): ArtifactEndpointContract | null {
if (!artifactsEnabled(flags)) return null;
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
normalizeIdentity({tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef,
surface: client.identity.surface});
const collection = scopedPath("/projects", scope);
function itemPath(artifactId: string): string {
if (!isOpaqueId(artifactId, "art")) throw new TypeError("Opaque artifact id required");
return scopedPath("/projects", scope, artifactId);
}
function itemEndpoint(artifactId: string, suffix = ""): string {
return client.endpoint(`${itemPath(artifactId)}${suffix}`);
}
return Object.freeze({
list: endpoint("GET", client.endpoint(collection), "hux.artifact_workspace.v1"),
item(artifactId) {
return endpoint("GET", itemEndpoint(artifactId), "hux.artifact_response.v1");
},
preview(artifactId, version) {
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/preview`),
"hux.artifact_preview.v1");
},
diff(artifactId, fromVersion, toVersion) {
if (fromVersion >= toVersion) throw new TypeError("Artifact diff range is invalid");
return endpoint("POST", itemEndpoint(artifactId, "/diffs"), "hux.artifact_diff.v1",
Object.freeze(["from_version", "to_version", "expected_current_version"]));
},
appendVersion(artifactId) {
return endpoint("POST", itemEndpoint(artifactId, "/versions"), "hux.artifact_response.v1",
Object.freeze(["content_ref", "expected_current_version"]));
},
continueEdit(artifactId) {
return endpoint("POST", itemEndpoint(artifactId, "/continue"), "hux.artifact_edit_intent.v1",
Object.freeze(["version", "expected_current_version"]));
},
authorize(artifactId, action) {
if (action !== "download" && action !== "share") throw new TypeError("Artifact action is invalid");
return endpoint("POST", itemEndpoint(artifactId, `/authorizations/${action}`),
"hux.artifact_authorization.v1", Object.freeze(["expected_current_version"]));
},
download(artifactId, version) {
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/download`),
"hux.artifact_content.v1");
},
share(artifactId, version) {
return endpoint("POST", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/shares`),
"hux.artifact_share.v1", Object.freeze(["expected_current_version"]));
},
promote(artifactId) {
return endpoint("POST", itemEndpoint(artifactId, "/promotions"), "hux.artifact_response.v1",
Object.freeze(["target_project_id", "version", "expected_current_version"]));
},
sources(artifactId) {
return endpoint("GET", itemEndpoint(artifactId, "/sources"), "hux.artifact_sources.v1");
},
});
}

View File

@ -0,0 +1,14 @@
export { ArtifactWorkspace } from "./ArtifactWorkspace.tsx";
export { createArtifactEndpointContract } from "./endpoints.ts";
export {
ARTIFACTS_FLAG,
FOUNDATION_FLAG,
PROJECTS_FLAG,
artifactsEnabled,
normalizeArtifact,
normalizeArtifactWorkspace,
normalizePreview,
preservesImmutableHistory,
} from "./model.ts";
export { ArtifactContractError } from "./security.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,239 @@
/** Fail-closed normalization and immutable-history invariants for HUX-04. */
import {
ArtifactContractError,
isMime,
isOpaqueId,
isSha256,
isUtc,
normalizeIdentity,
normalizeScope,
safeBlobUrl,
safePreviewText,
safeText,
sameIdentity,
sameScope,
} from "./security.ts";
import type {
Artifact,
ArtifactAttachments,
ArtifactAuthorization,
ArtifactPreview,
ArtifactRenderer,
ArtifactScope,
ArtifactType,
ArtifactVersion,
AuthorizationStatus,
HuxIdentity,
RawArtifact,
RawArtifactEnvelope,
RawArtifactVersion,
RawPreviewPayload,
Sensitivity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const ARTIFACTS_FLAG = "hux.artifacts";
const TYPES = new Set<ArtifactType>([
"markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio",
]);
const SENSITIVITIES = new Set<Sensitivity>(["public", "personal", "sensitive", "restricted"]);
const AUTHORIZATION = new Set<AuthorizationStatus>(["unavailable", "requires_approval", "authorized"]);
const ACTORS = new Set(["user", "assistant", "tool", "system", "operator"]);
const MAX_ARTIFACTS = 200;
const MAX_VERSIONS = 200;
export function artifactsEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return active.has(FOUNDATION_FLAG) && active.has(PROJECTS_FLAG) && active.has(ARTIFACTS_FLAG);
}
function rendererFor(type: ArtifactType, language: string): ArtifactRenderer {
if (type === "image") return "image";
if (type === "audio") return "audio";
if (["code", "html", "svg"].includes(type)) return "code";
if (["json", "csv"].includes(type)) return "data";
if (/^(report|research|analysis)$/i.test(language)) return "report";
return "document";
}
function mimeAllowed(type: ArtifactType, mime: string): boolean {
if (type === "image") return ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime);
if (type === "audio") return ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"].includes(mime);
if (type === "html") return mime === "text/html";
if (type === "svg") return mime === "image/svg+xml";
if (type === "json") return mime === "application/json" || mime === "text/plain";
if (type === "csv") return mime === "text/csv" || mime === "text/plain";
if (type === "markdown") return mime === "text/markdown" || mime === "text/plain";
if (type === "code") return mime.startsWith("text/") || mime === "application/json";
return mime === "application/pdf" || mime === "text/plain" || mime === "text/markdown";
}
function normalizeVersion(raw: RawArtifactVersion, type: ArtifactType): ArtifactVersion | null {
const actor = raw.created_by as Record<string, unknown> | null;
const content = raw.content_ref as Record<string, unknown> | null;
if (!Number.isInteger(raw.version) || (raw.version as number) < 1 || !isUtc(raw.created_at) ||
!actor || !ACTORS.has(String(actor.type)) || typeof actor.id !== "string" ||
actor.id.length < 1 || actor.id.length > 120 || !content ||
!isSha256(content.hash) || !Number.isSafeInteger(content.bytes) ||
(content.bytes as number) < 0 || !isMime(content.mime) ||
!mimeAllowed(type, content.mime) || (raw.diff_from !== undefined &&
(!Number.isInteger(raw.diff_from) || (raw.diff_from as number) < 1))) return null;
let lineage: ArtifactVersion["lineage"] = null;
if (raw.lineage !== undefined) {
const value = raw.lineage as Record<string, unknown> | null;
if (!value || !isOpaqueId(value.artifact_id, "art") ||
!Number.isInteger(value.version) || (value.version as number) < 1) return null;
lineage = {artifactId: value.artifact_id, version: value.version as number};
}
return {
version: raw.version as number,
createdAt: raw.created_at,
createdBy: String(actor.type),
contentRef: {hash: content.hash, bytes: content.bytes as number, mime: content.mime},
diffFrom: raw.diff_from as number | undefined || null,
lineage,
note: safeText(raw.note, "", 200),
};
}
export function normalizeArtifact(raw: RawArtifact, expectedOwner: string, scope: ArtifactScope): Artifact | null {
if (raw.schema !== "hux.artifact.v1" || !isOpaqueId(raw.id, "art") ||
raw.owner !== expectedOwner || raw.project_id !== scope.projectId ||
raw.conversation_id !== scope.conversationId || !TYPES.has(raw.type as ArtifactType) ||
!Number.isInteger(raw.current_version) || (raw.current_version as number) < 1 ||
!Array.isArray(raw.versions) || raw.versions.length < 1 || raw.versions.length > MAX_VERSIONS ||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || raw.updated_at < raw.created_at) return null;
const type = raw.type as ArtifactType;
const language = safeText(raw.language, "", 40);
const title = safeText(raw.title, "", 200);
if (!title) return null;
const versions = raw.versions.map((item) => normalizeVersion(item as RawArtifactVersion, type));
if (versions.some((item) => item === null)) return null;
const ordered = versions as ArtifactVersion[];
if (ordered.some((item, index) => item.version !== index + 1 ||
(index > 0 && item.createdAt < ordered[index - 1].createdAt) ||
(item.diffFrom !== null && item.diffFrom >= item.version) ||
(item.lineage?.artifactId === raw.id && item.lineage.version >= item.version)) ||
raw.current_version !== ordered.length) return null;
let promotedVersion: number | null = null;
if (raw.promotion !== undefined) {
const promotion = raw.promotion as Record<string, unknown> | null;
if (!promotion || promotion.project_id !== scope.projectId ||
!Number.isInteger(promotion.version) || (promotion.version as number) < 1 ||
(promotion.version as number) > ordered.length || !isUtc(promotion.at)) return null;
promotedVersion = promotion.version as number;
}
return {
id: raw.id, owner: expectedOwner, projectId: scope.projectId,
conversationId: scope.conversationId, type, renderer: rendererFor(type, language), language,
title, currentVersion: raw.current_version as number, versions: ordered,
sensitivity: raw.sensitivity as Sensitivity, promotedVersion, updatedAt: raw.updated_at,
};
}
function normalizeAttachments(raw: unknown, allowed: ReadonlySet<string>): ArtifactAttachments[] {
if (!Array.isArray(raw)) return [];
const result: ArtifactAttachments[] = [];
const seen = new Set<string>();
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
const value = item as Record<string, unknown> | null;
const sources = value?.source_ids;
const citations = value?.citation_ids;
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
seen.has(value.artifact_id) || !Array.isArray(sources) || !Array.isArray(citations) ||
sources.length > 500 || citations.length > 500 ||
sources.some((id) => !isOpaqueId(id, "src")) ||
citations.some((id) => !isOpaqueId(id, "cit")) ||
new Set(sources).size !== sources.length || new Set(citations).size !== citations.length) continue;
seen.add(value.artifact_id);
result.push({artifactId: value.artifact_id, sourceCount: sources.length, citationCount: citations.length});
}
return result;
}
function normalizeAuthorizations(raw: unknown, allowed: ReadonlySet<string>): ArtifactAuthorization[] {
if (!Array.isArray(raw)) return [];
const result: ArtifactAuthorization[] = [];
const seen = new Set<string>();
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
const value = item as Record<string, unknown> | null;
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
seen.has(value.artifact_id) || !AUTHORIZATION.has(value.download as AuthorizationStatus) ||
!AUTHORIZATION.has(value.share as AuthorizationStatus)) continue;
seen.add(value.artifact_id);
result.push({artifactId: value.artifact_id, download: value.download as AuthorizationStatus,
share: value.share as AuthorizationStatus});
}
return result;
}
export function normalizeArtifactWorkspace(raw: RawArtifactEnvelope, expected: HuxIdentity,
scope: ArtifactScope) {
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
user_ref: expected.userRef, surface: expected.surface});
if (!raw || typeof raw !== "object" || raw.schema !== "hux.artifact_workspace.v1" ||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), trustedIdentity) ||
!sameScope(normalizeScope(raw.binding), scope)) {
throw new ArtifactContractError("Artifact workspace crossed its scope boundary");
}
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
const normalized = records.slice(0, MAX_ARTIFACTS)
.map((item) => normalizeArtifact(item as RawArtifact, trustedIdentity.userRef, scope))
.filter((item): item is Artifact => item !== null);
const ids = new Set<string>();
const artifacts = normalized.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id)));
const allowed = new Set(artifacts.map((item) => item.id));
return {
artifacts,
attachments: normalizeAttachments(raw.attachments, allowed),
authorizations: normalizeAuthorizations(raw.authorizations, allowed),
rejected: records.length - artifacts.length,
};
}
export function normalizePreview(raw: RawPreviewPayload, artifact: Artifact, expected: HuxIdentity,
scope: ArtifactScope): ArtifactPreview | null {
try {
const identity = normalizeIdentity(raw.identity);
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
user_ref: expected.userRef, surface: expected.surface});
const binding = normalizeScope(raw.binding);
const version = artifact.versions.find((item) => item.version === raw.version);
if (raw.schema !== "hux.artifact_preview.v1" || !sameIdentity(identity, trustedIdentity) ||
!sameScope(binding, scope) || raw.artifact_id !== artifact.id || !version ||
raw.hash !== version.contentRef.hash || raw.mime !== version.contentRef.mime) return null;
if (artifact.renderer === "image" || artifact.renderer === "audio") {
const blobUrl = safeBlobUrl(raw.blob_url);
if (!blobUrl || raw.text !== undefined) return null;
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
mime: version.contentRef.mime, text: null, blobUrl};
}
if (raw.blob_url !== undefined) return null;
const text = safePreviewText(raw.text);
if (text === null) return null;
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
mime: version.contentRef.mime, text, blobUrl: null};
} catch {
return null;
}
}
function sameVersion(left: ArtifactVersion, right: ArtifactVersion): boolean {
return left.version === right.version && left.createdAt === right.createdAt &&
left.createdBy === right.createdBy && left.contentRef.hash === right.contentRef.hash &&
left.contentRef.bytes === right.contentRef.bytes && left.contentRef.mime === right.contentRef.mime &&
left.diffFrom === right.diffFrom && left.lineage?.artifactId === right.lineage?.artifactId &&
left.lineage?.version === right.lineage?.version && left.note === right.note;
}
/** A server mutation may append versions but never rewrite history or detach scope. */
export function preservesImmutableHistory(before: Artifact, after: Artifact): boolean {
return before.id === after.id && before.owner === after.owner &&
before.projectId === after.projectId && before.conversationId === after.conversationId &&
before.type === after.type && before.versions.length <= after.versions.length &&
before.versions.every((version, index) => sameVersion(version, after.versions[index])) &&
after.currentVersion === after.versions.length;
}

View File

@ -0,0 +1,104 @@
/** Identity, content-reference, URL, and display guards for HUX-04. */
import type { ArtifactScope, HuxIdentity } from "./types.ts";
const OPAQUE_ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const HASH = /^sha256:[0-9a-f]{64}$/;
const MIME = /^[a-z0-9][a-z0-9.+-]{0,63}\/[a-z0-9][a-z0-9.+-]{0,63}$/i;
const CONTROL = /[\u0000-\u001f\u007f]/g;
const PREVIEW_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
const CREDENTIALS = [
/\b(?:password|passwd|token|secret|api[_-]?key)\s*[:=]\s*\S+/gi,
/\bBearer\s+[A-Za-z0-9._~-]+/gi,
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi,
];
export class ArtifactContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ArtifactContractError";
}
}
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && OPAQUE_ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function isSha256(value: unknown): value is string {
return typeof value === "string" && HASH.test(value);
}
export function isMime(value: unknown): value is string {
return typeof value === "string" && value.length <= 120 && MIME.test(value);
}
export function safeText(value: unknown, fallback: string, limit: number): string {
if (typeof value !== "string") return fallback;
let clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
for (const pattern of CREDENTIALS) clean = clean.replace(pattern, "[redacted]");
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
export function safePreviewText(value: unknown, limit = 200_000): string | null {
return typeof value === "string" && value.length <= limit && !PREVIEW_CONTROL.test(value) ? value : null;
}
export function normalizeIdentity(raw: unknown): HuxIdentity {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new ArtifactContractError("Artifact identity is invalid");
}
const value = raw as Record<string, unknown>;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
typeof value.surface !== "string" || !SURFACES.has(value.surface)) {
throw new ArtifactContractError("Artifact identity is invalid");
}
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
surface: value.surface as HuxIdentity["surface"]};
}
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function normalizeScope(raw: unknown): ArtifactScope {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new ArtifactContractError("Artifact binding is invalid");
}
const value = raw as Record<string, unknown>;
if (!isOpaqueId(value.project_id, "prj") || !isOpaqueId(value.conversation_id, "conv")) {
throw new ArtifactContractError("Artifact binding is invalid");
}
return {projectId: value.project_id, conversationId: value.conversation_id};
}
export function sameScope(left: ArtifactScope, right: ArtifactScope): boolean {
return left.projectId === right.projectId && left.conversationId === right.conversationId;
}
/** Only local object URLs created from an authorized response may reach img/audio. */
export function safeBlobUrl(value: unknown): string | null {
return typeof value === "string" && /^blob:[^\s]{1,2000}$/.test(value) ? value : null;
}
export function scopedPath(base: string, scope: ArtifactScope, artifactId?: string): string {
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..") ||
!isOpaqueId(scope.projectId, "prj") || !isOpaqueId(scope.conversationId, "conv") ||
(artifactId !== undefined && !isOpaqueId(artifactId, "art"))) {
throw new TypeError("Artifact endpoint scope is invalid");
}
const parts = [base.replace(/\/$/, ""), scope.projectId, "conversations", scope.conversationId, "artifacts"];
if (artifactId) parts.push(artifactId);
return parts.map((part, index) => index ? encodeURIComponent(part) : part).join("/");
}

View File

@ -0,0 +1,294 @@
.hux-artifacts {
color: var(--hux-text, #dce8f4);
display: grid;
gap: 0.8rem;
grid-template-columns: minmax(12rem, 0.75fr) minmax(20rem, 2fr) minmax(13rem, 0.85fr);
min-height: 30rem;
}
.hux-artifact-list,
.hux-artifact-main,
.hux-artifact-side {
background: color-mix(in srgb, currentColor 2.5%, transparent);
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
border-radius: 0.9rem;
min-width: 0;
padding: 0.8rem;
}
.hux-artifact-list header h2,
.hux-artifact-history h3,
.hux-artifact-list button,
.hux-artifact-actions,
.hux-artifact-actions button,
.hux-artifact-heading,
.hux-artifact-preview-empty,
.hux-artifact-evidence,
.hux-artifact-promote,
.hux-artifact-diff {
align-items: center;
display: flex;
}
.hux-artifact-list header h2,
.hux-artifact-history h3 {
font-size: 0.9rem;
gap: 0.4rem;
margin: 0;
}
.hux-artifact-list header p,
.hux-artifact-heading p,
.hux-artifact-empty {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.7rem;
margin: 0.25rem 0 0;
}
.hux-artifact-list ul,
.hux-artifact-history ol {
display: grid;
gap: 0.4rem;
list-style: none;
margin: 0.8rem 0 0;
padding: 0;
}
.hux-artifact-list button,
.hux-artifact-history button,
.hux-artifact-actions button,
.hux-artifact-promote,
.hux-artifact-diff {
background: color-mix(in srgb, currentColor 4%, transparent);
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
border-radius: 0.6rem;
color: inherit;
cursor: pointer;
font: inherit;
}
.hux-artifact-list button {
gap: 0.55rem;
padding: 0.55rem;
text-align: left;
width: 100%;
}
.hux-artifact-list button[aria-current="page"] {
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 11%, transparent);
border-color: color-mix(in srgb, var(--hux-accent, #25d4c3) 45%, transparent);
}
.hux-artifact-list button > span {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.hux-artifact-list strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hux-artifact-list small {
color: color-mix(in srgb, currentColor 55%, transparent);
text-transform: capitalize;
}
.hux-artifact-list svg,
.hux-artifact-actions svg,
.hux-artifact-history svg,
.hux-artifact-preview-empty svg,
.hux-artifact-promote svg {
flex: 0 0 auto;
height: 0.95rem;
width: 0.95rem;
}
.hux-artifact-heading {
align-items: start;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: space-between;
}
.hux-artifact-heading > div:first-child > span {
color: var(--hux-accent, #25d4c3);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hux-artifact-heading h2 {
font-size: 1rem;
margin: 0.15rem 0;
}
.hux-artifact-actions {
flex-wrap: wrap;
gap: 0.4rem;
justify-content: flex-end;
}
.hux-artifact-actions button,
.hux-artifact-promote,
.hux-artifact-diff {
gap: 0.35rem;
padding: 0.4rem 0.55rem;
}
.hux-artifacts button:focus-visible,
.hux-artifacts pre:focus-visible {
outline: 2px solid var(--hux-accent, #25d4c3);
outline-offset: 2px;
}
.hux-artifacts button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.hux-artifact-document,
.hux-artifact-code,
.hux-artifact-report,
.hux-artifact-data,
.hux-artifact-image,
.hux-artifact-audio,
.hux-artifact-preview-empty {
background: color-mix(in srgb, #050a12 70%, transparent);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 0.75rem;
margin: 0.85rem 0;
min-height: 15rem;
overflow: auto;
padding: 0.8rem;
}
.hux-artifact-report h3 {
color: var(--hux-accent, #25d4c3);
font-size: 0.7rem;
letter-spacing: 0.08em;
margin: 0 0 0.65rem;
text-transform: uppercase;
}
.hux-artifact-document pre,
.hux-artifact-code pre,
.hux-artifact-report pre,
.hux-artifact-data pre {
font: 0.76rem/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.hux-artifact-preview-empty {
align-content: center;
color: color-mix(in srgb, currentColor 55%, transparent);
flex-direction: column;
justify-content: center;
text-align: center;
}
.hux-artifact-image {
display: grid;
place-items: center;
}
.hux-artifact-image img {
max-height: 32rem;
max-width: 100%;
object-fit: contain;
}
.hux-artifact-image figcaption,
.hux-artifact-audio span {
color: color-mix(in srgb, currentColor 55%, transparent);
font-size: 0.65rem;
margin-top: 0.5rem;
}
.hux-artifact-audio {
align-content: center;
display: grid;
}
.hux-artifact-audio audio { width: 100%; }
.hux-artifact-evidence {
gap: 0.55rem;
margin: 0.8rem 0;
}
.hux-artifact-evidence div {
background: color-mix(in srgb, currentColor 4%, transparent);
border-radius: 0.55rem;
display: grid;
flex: 1;
padding: 0.55rem;
}
.hux-artifact-evidence strong { color: var(--hux-accent, #25d4c3); }
.hux-artifact-evidence span { font-size: 0.65rem; }
.hux-artifact-promote { margin-left: auto; }
.hux-artifact-history ol > li {
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
display: grid;
gap: 0.35rem;
padding-bottom: 0.45rem;
}
.hux-artifact-history ol > li[data-current="true"] { border-color: var(--hux-accent, #25d4c3); }
.hux-artifact-history li > button:first-child {
display: grid;
gap: 0.15rem;
padding: 0.48rem;
text-align: left;
width: 100%;
}
.hux-artifact-history li > button[aria-pressed="true"] {
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 9%, transparent);
}
.hux-artifact-history span,
.hux-artifact-history time {
color: color-mix(in srgb, currentColor 56%, transparent);
font-size: 0.63rem;
}
.hux-artifact-diff { font-size: 0.68rem; justify-self: end; }
.hux-artifact-error,
.hux-artifact-warning {
border-left: 2px solid #efb756;
color: #efcf96;
font-size: 0.72rem;
margin: 0 0 0.7rem;
padding-left: 0.55rem;
}
.hux-artifact-error { border-color: #e36b7c; color: #f0a4ae; }
@media (max-width: 66rem) {
.hux-artifacts { grid-template-columns: minmax(12rem, 0.7fr) minmax(20rem, 2fr); }
.hux-artifact-side { grid-column: 1 / -1; }
.hux-artifact-history ol { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
}
@media (max-width: 42rem) {
.hux-artifacts { grid-template-columns: 1fr; }
.hux-artifact-side { grid-column: auto; }
.hux-artifact-heading { align-items: stretch; }
.hux-artifact-actions { justify-content: stretch; }
.hux-artifact-actions button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
.hux-artifacts * { scroll-behavior: auto; transition: none; }
}

View File

@ -0,0 +1,174 @@
/** Safe frontend contracts for the default-off HUX-04 artifact workspace. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type ArtifactType =
| "markdown"
| "code"
| "html"
| "svg"
| "image"
| "json"
| "csv"
| "document"
| "audio";
export type ArtifactRenderer = "document" | "code" | "image" | "report" | "data" | "audio";
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
export type AuthorizationStatus = "unavailable" | "requires_approval" | "authorized";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface ArtifactScope {
projectId: string;
conversationId: string;
}
export interface RawArtifactVersion {
version?: unknown;
created_at?: unknown;
created_by?: unknown;
message_id?: unknown;
content_ref?: unknown;
diff_from?: unknown;
lineage?: unknown;
note?: unknown;
}
export interface RawArtifact {
schema?: unknown;
id?: unknown;
owner?: unknown;
conversation_id?: unknown;
project_id?: unknown;
type?: unknown;
language?: unknown;
title?: unknown;
current_version?: unknown;
versions?: unknown;
promotion?: unknown;
sensitivity?: unknown;
created_at?: unknown;
updated_at?: unknown;
}
export interface RawArtifactEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
binding?: unknown;
artifacts?: unknown;
attachments?: unknown;
authorizations?: unknown;
}
export interface ContentReference {
hash: string;
bytes: number;
mime: string;
}
export interface ArtifactVersion {
version: number;
createdAt: string;
createdBy: string;
contentRef: ContentReference;
diffFrom: number | null;
lineage: {artifactId: string; version: number} | null;
note: string;
}
export interface Artifact {
id: string;
owner: string;
projectId: string;
conversationId: string;
type: ArtifactType;
renderer: ArtifactRenderer;
language: string;
title: string;
currentVersion: number;
versions: ArtifactVersion[];
sensitivity: Sensitivity;
promotedVersion: number | null;
updatedAt: string;
}
export interface ArtifactAttachments {
artifactId: string;
sourceCount: number;
citationCount: number;
}
export interface ArtifactAuthorization {
artifactId: string;
download: AuthorizationStatus;
share: AuthorizationStatus;
}
export interface ArtifactWorkspacePage {
artifacts: Artifact[];
attachments: ArtifactAttachments[];
authorizations: ArtifactAuthorization[];
rejected: number;
}
export interface RawPreviewPayload {
schema?: unknown;
identity?: unknown;
binding?: unknown;
artifact_id?: unknown;
version?: unknown;
hash?: unknown;
mime?: unknown;
text?: unknown;
blob_url?: unknown;
}
export interface ArtifactPreview {
artifactId: string;
version: number;
renderer: ArtifactRenderer;
mime: string;
text: string | null;
blobUrl: string | null;
}
export interface ArtifactMutationIntent {
artifactId: string;
expectedVersion: number;
scope: ArtifactScope;
}
export interface PromoteIntent extends ArtifactMutationIntent {
targetProjectId: string;
version: number;
}
export interface DiffIntent extends ArtifactMutationIntent {
fromVersion: number;
toVersion: number;
}
export interface ArtifactEndpoint {
method: "GET" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface ArtifactEndpointContract {
list: ArtifactEndpoint;
item(artifactId: string): ArtifactEndpoint;
preview(artifactId: string, version: number): ArtifactEndpoint;
diff(artifactId: string, fromVersion: number, toVersion: number): ArtifactEndpoint;
appendVersion(artifactId: string): ArtifactEndpoint;
continueEdit(artifactId: string): ArtifactEndpoint;
authorize(artifactId: string, action: "download" | "share"): ArtifactEndpoint;
download(artifactId: string, version: number): ArtifactEndpoint;
share(artifactId: string, version: number): ArtifactEndpoint;
promote(artifactId: string): ArtifactEndpoint;
sources(artifactId: string): ArtifactEndpoint;
}

View File

@ -0,0 +1,181 @@
/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */
import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import {
AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS,
autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate,
buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt,
} from "./model.ts";
import type {
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent,
RawAutonomyEnvelope, StopIntent,
} from "./types.ts";
interface AutonomyControlsProps {
flags?: Iterable<string>;
identity: HuxIdentity;
conversationId: string;
page: RawAutonomyEnvelope;
activeRun?: ActiveRun | null;
stopReceipt?: Record<string, unknown> | null;
now?: number;
busy?: boolean;
onUpdatePolicy?: (intent: PolicyUpdateIntent) => void;
onDecideApproval?: (intent: ApprovalDecisionIntent) => void;
onStop?: (intent: StopIntent) => void;
}
const LABELS: Readonly<Record<Capability, string>> = {
read_files: "Read files", write_files: "Write files", shell: "Run commands",
network: "Use network", web_search: "Search the web", send_message: "Send messages",
memory_write: "Save memory", artifact_write: "Change artifacts",
spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes",
};
const MODES: Readonly<Record<AutonomyLevel, {label: string; help: string}>> = {
ask_first: {label: "Ask First", help: "Pause before actions until you approve."},
safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."},
autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."},
};
function startingBudget(page: ReturnType<typeof normalizeAutonomyPage>): BudgetDraft {
return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"};
}
function CapabilityMatrix({grants, setGrant, busy}: {
grants: Readonly<Record<Capability, GrantDecision>>;
setGrant: (capability: Capability, decision: GrantDecision) => void;
busy: boolean;
}) {
return <fieldset className="hux-autonomy-matrix"><legend>Capability matrix</legend>
<p id="hux-autonomy-matrix-help">Missing or invalid grants are denied.</p>
<table><thead><tr><th scope="col">Capability</th><th scope="col">Decision</th></tr></thead>
<tbody>{CAPABILITIES.map((capability) => <tr key={capability}>
<th scope="row">{LABELS[capability]}</th><td><select
aria-label={`${LABELS[capability]} decision`} aria-describedby="hux-autonomy-matrix-help"
value={grants[capability]} disabled={busy}
onChange={(event) => setGrant(capability, event.target.value as GrantDecision)}>
{GRANT_DECISIONS.map((decision) => <option key={decision} value={decision}>
{decision === "allow" ? "Allow" : decision === "ask" ? "Ask" : "Deny"}
</option>)}</select></td>
</tr>)}</tbody></table>
</fieldset>;
}
function BudgetControls({budget, setBudget, busy}: {
budget: BudgetDraft;
setBudget: (next: BudgetDraft) => void;
busy: boolean;
}) {
const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) =>
<label>{label}<input type="number" min="0" max={BUDGET_MAX[field]} step={step}
value={budget[field]} disabled={busy} onChange={(event) => setBudget({...budget,
[field]: Number(event.target.value)})} /></label>;
return <fieldset className="hux-autonomy-budgets"><legend>Per-run budgets</legend>
<p id="hux-autonomy-budget-help">Zero blocks that resource. Limits are enforced together.</p>
<div aria-describedby="hux-autonomy-budget-help">
{number("wallClockSeconds", "Time (seconds)")}
{number("spendCentsPerRun", "Spend (cents)")}
{number("tokensPerRun", "Tokens", 1000)}
{number("toolCallsPerRun", "Tool calls")}
{number("delegationsPerRun", "Subagents")}
<label>Maximum scope<select value={budget.scopeLimit} disabled={busy}
onChange={(event) => setBudget({...budget, scopeLimit: event.target.value as BudgetDraft["scopeLimit"]})}>
<option value="current_task">Current task</option><option value="conversation">Conversation</option>
<option value="project">Project</option></select></label>
</div>
</fieldset>;
}
function ApprovalQueue({items, busy, decide}: {
items: readonly ApprovalView[];
busy: boolean;
decide: (approval: ApprovalView, choice: ApprovalChoice) => void;
}) {
return <section className="hux-autonomy-approvals" aria-labelledby="hux-approval-title">
<h3 id="hux-approval-title"><Hand aria-hidden /> Review external side effects</h3>
{items.length ? <ol>{items.map((approval) => <li key={approval.id} data-risk={approval.risk}>
<strong>{LABELS[approval.capability]}</strong><p>{approval.summary}</p>
<small>Risk: {approval.risk}. Expires <time dateTime={approval.expiresAt}>
{new Date(approval.expiresAt).toLocaleTimeString()}</time>.</small>
<div aria-label="Approval choices">
<button type="button" disabled={busy} onClick={() => decide(approval, "once")}>Allow once</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "session")}>This session</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "always")}>Always</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "deny")}><Ban aria-hidden /> Deny</button>
</div>
</li>)}</ol> : <p>No actions are waiting for approval.</p>}
</section>;
}
/** Render nothing unless foundation, timeline, and autonomy are negotiated. */
export function AutonomyControls(props: AutonomyControlsProps) {
const enabled = autonomyControlsEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity,
props.conversationId, props.now) : null,
[enabled, props.page, props.identity, props.conversationId, props.now]);
const [autonomy, setAutonomy] = useState<AutonomyLevel>("ask_first");
const [grants, setGrants] = useState<Readonly<Record<Capability, GrantDecision>> | null>(null);
const [budget, setBudget] = useState<BudgetDraft | null>(null);
if (!enabled) return null;
if (!page) return <section className="hux-autonomy" role="alert">
Autonomy controls are unavailable because identity or policy scope could not be verified.
</section>;
const selectedAutonomy = grants ? autonomy : page.policy.autonomy;
const selectedGrants = grants || page.policy.grants;
const selectedBudget = budget || startingBudget(page);
const receipt = props.stopReceipt && props.activeRun ?
normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null;
function decide(approval: ApprovalView, choice: ApprovalChoice) {
try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity,
approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ }
}
function stop() {
if (!props.activeRun) return;
try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); }
catch { /* an unowned run is never stopped */ }
}
return <section className="hux-autonomy" aria-labelledby="hux-autonomy-title" aria-busy={props.busy}>
<header><div><h2 id="hux-autonomy-title"><ShieldCheck aria-hidden /> Autonomy</h2>
<p>Control what Hermes may do, how far it may go, and what always pauses.</p></div>
<button className="hux-autonomy-stop" type="button" disabled={props.busy || !props.activeRun}
onClick={stop}><Octagon aria-hidden /> Stop current run</button>
</header>
<form onSubmit={(event) => { event.preventDefault();
try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity,
selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ }
}}>
<fieldset className="hux-autonomy-levels"><legend>Autonomy level</legend>
{AUTONOMY_LEVELS.map((level) => <label key={level}>
<input type="radio" name="hux-autonomy-level" value={level}
checked={selectedAutonomy === level} disabled={props.busy}
onChange={() => {setAutonomy(level); setGrants({...selectedGrants});}} />
<span><strong>{MODES[level].label}</strong><small>{MODES[level].help}</small></span>
</label>)}
</fieldset>
<CapabilityMatrix grants={selectedGrants} busy={!!props.busy}
setGrant={(capability, decision) => setGrants({...selectedGrants, [capability]: decision})} />
<BudgetControls budget={selectedBudget} busy={!!props.busy} setBudget={setBudget} />
<button className="hux-autonomy-save" type="submit" disabled={props.busy}>Save controls</button>
</form>
{page.rejectedApprovals > 0 && <p role="status" className="hux-autonomy-warning">
Some approval requests were withheld because they were stale or out of scope.</p>}
<ApprovalQueue items={page.approvals} busy={!!props.busy} decide={decide} />
{receipt && <section className="hux-autonomy-receipt" aria-labelledby="hux-stop-receipt-title">
<h3 id="hux-stop-receipt-title">Stop receipt</h3><p>Outcome: {receipt.outcome}.</p>
<p>{receipt.stopped} effects reverted; {receipt.remaining} remain.</p>
<ul>{receipt.effects.map((effect, index) => <li key={index}>
{effect.description} — {effect.reverted ? "reverted" : "remaining"}</li>)}</ul>
{receipt.omittedEffects > 0 && <p>{receipt.omittedEffects} additional effects omitted.</p>}
</section>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`}
</p>
</section>;
}

View File

@ -0,0 +1,48 @@
/** Same-origin endpoint descriptors for the future HUX-05 backend. */
import { ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG } from "./model.ts";
import { isOpaqueId, normalizeIdentity, sameIdentity } from "./security.ts";
import type { AutonomyEndpoint, AutonomyEndpointContract, HuxIdentity } from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
function endpoint(method: AutonomyEndpoint["method"], path: string,
responseSchema: string, requiredBody?: readonly string[]): AutonomyEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
/** Describe, but never call, the tenant-scoped HUX-05 interface. */
export function createAutonomyEndpointContract(client: FoundationClient): AutonomyEndpointContract | null {
if (client.apiVersion !== "hux.v1" ||
![FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG].every((flag) => client.enabled(flag))) return null;
const identity = normalizeIdentity(client.identity);
if (!identity || !sameIdentity(identity, client.identity)) throw new TypeError("Scoped HUX identity required");
const scoped = (root: string, id: string, prefix: string): string => {
if (!isOpaqueId(id, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
return client.endpoint(`${root}/${encodeURIComponent(id)}`);
};
return Object.freeze({
identity: Object.freeze({...identity}),
policy: endpoint("GET", client.endpoint("/autonomy/policy"), "hux.policy.v1"),
updatePolicy: endpoint("PUT", client.endpoint("/autonomy/policy"), "hux.policy.v1",
["expected_policy_id", "expected_updated_at", "autonomy", "grants", "budgets", "guardrails"]),
approvals: endpoint("GET", client.endpoint("/approvals?status=pending&limit=100"), "hux.approval_page.v1"),
decideApproval(approvalId: string) {
return endpoint("POST", `${scoped("/approvals", approvalId, "apr")}/decision`, "hux.approval.v1",
["expected_run_id", "expected_conversation_id", "expected_capability", "expected_requested_at", "choice"]);
},
stop(runId: string) {
if (!/^run_[A-Za-z0-9._-]{1,116}$/.test(runId)) throw new TypeError("Exact run id required");
return endpoint("POST", client.endpoint(`/runs/${encodeURIComponent(runId)}/stop`),
"hux.cancel_receipt.v1", ["conversation_id"]);
},
release: Object.freeze({schema: "hux.release.v1" as const,
requiredState: "live_verified" as const,
featureFlags: Object.freeze([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG])}),
});
}

View File

@ -0,0 +1,12 @@
export { AutonomyControls } from "./AutonomyControls.tsx";
export { createAutonomyEndpointContract } from "./endpoints.ts";
export {
ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES,
FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled,
buildApprovalDecision, buildPolicyUpdate, buildStopIntent,
normalizeAutonomyPage, normalizeStopReceipt,
} from "./model.ts";
export type {
ActiveRun, ApprovalDecisionIntent, ApprovalView, AutonomyPage, BudgetDraft,
Capability, HuxIdentity, PolicyUpdateIntent, StopIntent, StopReceiptView,
} from "./types.ts";

View File

@ -0,0 +1,191 @@
/** Schema normalization and fail-closed decisions for HUX-05. */
import { normalizeCancellationReceipt } from "../activity/model.ts";
import {
boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity,
safeText, sameIdentity,
} from "./security.ts";
import type {
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
AutonomyPage, BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyScope,
PolicyUpdateIntent, PolicyView, RawAutonomyEnvelope, StopIntent, StopReceiptView,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
export const AUTONOMY_FLAG = "hux.autonomy";
export const CAPABILITIES: readonly Capability[] = [
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
"external_side_effect",
];
export const AUTONOMY_LEVELS: readonly AutonomyLevel[] = ["ask_first", "safe", "autonomous"];
export const GRANT_DECISIONS: readonly GrantDecision[] = ["allow", "ask", "deny"];
export const BUDGET_MAX = Object.freeze({
tokensPerRun: 2_000_000, toolCallsPerRun: 500, wallClockSeconds: 86_400,
delegationsPerRun: 32, spendCentsPerRun: 100_000,
});
const EXTERNAL = new Set<Capability>([
"write_files", "shell", "network", "send_message", "memory_write",
"artifact_write", "spend_tokens", "delegate", "deploy",
"external_side_effect",
]);
export function autonomyControlsEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const values = new Set(flags);
return [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]
.every((flag) => values.has(flag));
}
function normalizeScope(raw: unknown): PolicyScope | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
if (!exactKeys(value, value.level === "global" ? ["level"] : ["level", "scope_id"])) return null;
if (!["global", "project", "conversation"].includes(String(value.level))) return null;
if (value.level !== "global" && !isOpaqueId(value.scope_id)) return null;
return {level: value.level as PolicyScope["level"],
...(value.level === "global" ? {} : {scopeId: value.scope_id as string})};
}
function normalizePolicy(raw: unknown, expected: HuxIdentity): PolicyView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const required = ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"];
if (!exactKeys(value, required) || value.schema !== "hux.policy.v1" ||
!isOpaqueId(value.id, "pol") || value.owner !== expected.userRef ||
!AUTONOMY_LEVELS.includes(value.autonomy as AutonomyLevel) || !isUtc(value.updated_at)) return null;
const scope = normalizeScope(value.scope);
if (!scope || !Array.isArray(value.grants) || value.grants.length > 64 ||
!value.budgets || typeof value.budgets !== "object" || Array.isArray(value.budgets)) return null;
const grants = Object.fromEntries(CAPABILITIES.map((capability) => [capability, "deny"])) as Record<Capability, GrantDecision>;
const seen = new Set<Capability>();
for (const rawGrant of value.grants) {
if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) return null;
const grant = rawGrant as Record<string, unknown>;
if (!CAPABILITIES.includes(grant.capability as Capability) ||
!GRANT_DECISIONS.includes(grant.decision as GrantDecision) || seen.has(grant.capability as Capability)) return null;
seen.add(grant.capability as Capability);
grants[grant.capability as Capability] = grant.decision as GrantDecision;
}
const budget = value.budgets as Record<string, unknown>;
const budgetKeys = ["tokens_per_run", "tool_calls_per_run", "wall_clock_seconds", "delegations_per_run"];
if (Object.keys(budget).some((key) => !budgetKeys.includes(key))) return null;
const normalized = {
tokensPerRun: boundedInteger(budget.tokens_per_run ?? 0, BUDGET_MAX.tokensPerRun),
toolCallsPerRun: boundedInteger(budget.tool_calls_per_run ?? 0, BUDGET_MAX.toolCallsPerRun),
wallClockSeconds: boundedInteger(budget.wall_clock_seconds ?? 0, BUDGET_MAX.wallClockSeconds),
delegationsPerRun: boundedInteger(budget.delegations_per_run ?? 0, BUDGET_MAX.delegationsPerRun),
};
if (Object.values(normalized).some((item) => item === null)) return null;
return {id: value.id, owner: value.owner as string, scope,
autonomy: value.autonomy as AutonomyLevel, grants: Object.freeze({...grants}),
budgets: normalized as PolicyView["budgets"], updatedAt: value.updated_at};
}
function normalizeApproval(raw: unknown, expected: HuxIdentity, conversationId: string,
now: number): ApprovalView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const keys = ["schema", "id", "run_id", "conversation_id", "capability", "request",
"status", "requested_at", "expires_at"];
if (!exactKeys(value, keys) || value.schema !== "hux.approval.v1" ||
!isOpaqueId(value.id, "apr") || typeof value.run_id !== "string" || !value.run_id ||
value.run_id.length > 120 || value.conversation_id !== conversationId ||
!CAPABILITIES.includes(value.capability as Capability) || value.status !== "pending" ||
!isUtc(value.requested_at) || !isUtc(value.expires_at) ||
Date.parse(value.requested_at) > Date.parse(value.expires_at) || Date.parse(value.expires_at) <= now) return null;
if (!value.request || typeof value.request !== "object" || Array.isArray(value.request)) return null;
const request = value.request as Record<string, unknown>;
if (Object.keys(request).some((key) => !["summary", "detail", "risk", "evidence"].includes(key)) ||
!["low", "medium", "high"].includes(String(request.risk))) return null;
return {id: value.id, identityKey: identityKey(expected), runId: value.run_id,
conversationId, capability: value.capability as Capability,
summary: safeText(request.summary, "Hermes requests permission"),
risk: request.risk as ApprovalView["risk"], requestedAt: value.requested_at,
expiresAt: value.expires_at, status: "pending"};
}
export function normalizeAutonomyPage(raw: RawAutonomyEnvelope, expected: HuxIdentity,
conversationId: string, now = Date.now()): AutonomyPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.autonomy_page.v1" ||
raw.api_version !== "hux.v1" || raw.conversation_id !== conversationId || !isOpaqueId(conversationId, "conv")) return null;
const actual = normalizeIdentity(raw.identity);
if (!actual || !sameIdentity(actual, expected)) return null;
const policy = normalizePolicy(raw.policy, expected);
if (!policy || !Array.isArray(raw.approvals) || raw.approvals.length > 100) return null;
const approvals = raw.approvals.map((item) => normalizeApproval(item, expected, conversationId, now));
return {policy, approvals: approvals.filter((item): item is ApprovalView => item !== null),
rejectedApprovals: approvals.filter((item) => item === null).length};
}
function boundedDraft(draft: BudgetDraft): boolean {
return boundedInteger(draft.tokensPerRun, BUDGET_MAX.tokensPerRun) !== null &&
boundedInteger(draft.toolCallsPerRun, BUDGET_MAX.toolCallsPerRun) !== null &&
boundedInteger(draft.wallClockSeconds, BUDGET_MAX.wallClockSeconds) !== null &&
boundedInteger(draft.delegationsPerRun, BUDGET_MAX.delegationsPerRun) !== null &&
boundedInteger(draft.spendCentsPerRun, BUDGET_MAX.spendCentsPerRun) !== null &&
["current_task", "conversation", "project"].includes(draft.scopeLimit);
}
export function buildPolicyUpdate(policy: PolicyView, identity: HuxIdentity,
autonomy: AutonomyLevel, grants: Readonly<Record<Capability, GrantDecision>>,
draft: BudgetDraft): PolicyUpdateIntent {
if (policy.owner !== identity.userRef || !AUTONOMY_LEVELS.includes(autonomy) || !boundedDraft(draft) ||
CAPABILITIES.some((capability) => !GRANT_DECISIONS.includes(grants[capability]))) {
throw new TypeError("Autonomy policy update is outside its bounds");
}
return {expectedPolicyId: policy.id, expectedUpdatedAt: policy.updatedAt,
identity: {...identity}, scope: {...policy.scope}, autonomy, grants: Object.freeze({...grants}),
budgets: {tokensPerRun: draft.tokensPerRun, toolCallsPerRun: draft.toolCallsPerRun,
wallClockSeconds: draft.wallClockSeconds, delegationsPerRun: draft.delegationsPerRun},
guardrails: {spendCentsPerRun: draft.spendCentsPerRun, scopeLimit: draft.scopeLimit}};
}
export function buildApprovalDecision(approval: ApprovalView, choice: ApprovalChoice,
identity: HuxIdentity, expectedRequestedAt: string, now = Date.now()): ApprovalDecisionIntent {
if (approval.identityKey !== identityKey(identity) || approval.requestedAt !== expectedRequestedAt ||
approval.status !== "pending" || Date.parse(approval.expiresAt) <= now ||
!["once", "session", "always", "deny"].includes(choice)) {
throw new TypeError("Stale or mismatched approval denied");
}
return {approvalId: approval.id, expectedRunId: approval.runId,
expectedConversationId: approval.conversationId, expectedCapability: approval.capability,
expectedRequestedAt, choice, identity: {...identity}};
}
export function actionAuthorization(policy: PolicyView | null, capability: Capability,
_approval: ApprovalView | null, identity: HuxIdentity, _now = Date.now()): "allow" | "approval_required" | "deny" {
if (!policy || policy.owner !== identity.userRef || !CAPABILITIES.includes(capability)) return "deny";
const decision = policy.grants[capability];
if (decision === "deny") return "deny";
const mustAsk = decision === "ask" || policy.autonomy === "ask_first" ||
(policy.autonomy === "safe" && EXTERNAL.has(capability));
if (!mustAsk) return "allow";
// A pending UI record is never execution authority. The server may perform
// the action only after its decision endpoint atomically validates and
// consumes the exact approval proof built by buildApprovalDecision().
return "approval_required";
}
export function buildStopIntent(run: ActiveRun, identity: HuxIdentity,
expectedRunId: string): StopIntent {
if (run.runId !== expectedRunId || !run.runId || run.runId.length > 120 ||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef ||
!isOpaqueId(run.conversationId, "conv")) throw new TypeError("Run ownership could not be verified");
return {runId: run.runId, conversationId: run.conversationId, identity: {...identity}};
}
export function normalizeStopReceipt(raw: Record<string, unknown>, run: ActiveRun,
identity: HuxIdentity): StopReceiptView | null {
const requestedBy = raw?.requested_by as Record<string, unknown> | null;
if (!requestedBy || requestedBy.type !== "user" || requestedBy.id !== identity.userRef ||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef || raw.run_id !== run.runId) return null;
const receipt = normalizeCancellationReceipt(raw, identity.surface);
if (!receipt || receipt.runId !== run.runId) return null;
return {id: receipt.id, runId: receipt.runId, outcome: receipt.outcome,
stopped: receipt.sideEffects.filter((item) => item.reverted).length,
remaining: receipt.sideEffects.filter((item) => !item.reverted).length,
effects: receipt.sideEffects, omittedEffects: receipt.omittedEffects};
}

View File

@ -0,0 +1,59 @@
/** Small, fail-closed validation helpers for HUX-05. */
import type { HuxIdentity } from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit = 280): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(SECRET, "$1=[redacted]").replace(BEARER, "Bearer [redacted]")
.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const tenantRef = value.tenant_ref ?? value.tenantRef;
const userRef = value.user_ref ?? value.userRef;
const surfaces = new Set(["chat", "worker", "telegram", "voice", "api"]);
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
typeof userRef !== "string" || !USER.test(userRef) ||
typeof value.surface !== "string" || !surfaces.has(value.surface)) return null;
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
}
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function identityKey(value: HuxIdentity): string {
return `${value.tenantRef}:${value.userRef}:${value.surface}`;
}
export function exactKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]);
}
export function boundedInteger(value: unknown, maximum: number): number | null {
return Number.isSafeInteger(value) && (value as number) >= 0 &&
(value as number) <= maximum ? value as number : null;
}

View File

@ -0,0 +1,165 @@
.hux-autonomy {
color: var(--hermes-text, #e8edf7);
display: grid;
gap: 1rem;
}
.hux-autonomy > header {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.hux-autonomy h2,
.hux-autonomy h3 {
align-items: center;
display: flex;
gap: 0.45rem;
margin: 0;
}
.hux-autonomy form {
display: grid;
gap: 1rem;
}
.hux-autonomy fieldset,
.hux-autonomy-approvals,
.hux-autonomy-receipt {
border: 1px solid color-mix(in srgb, var(--hermes-accent, #32d6d2) 30%, transparent);
border-radius: 0.8rem;
padding: 1rem;
}
.hux-autonomy-levels {
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.hux-autonomy-levels label {
align-items: start;
background: color-mix(in srgb, var(--hermes-panel, #121a27) 90%, transparent);
border-radius: 0.6rem;
display: flex;
gap: 0.55rem;
padding: 0.75rem;
}
.hux-autonomy-levels small,
.hux-autonomy-approvals small {
display: block;
opacity: 0.76;
}
.hux-autonomy-matrix table {
border-collapse: collapse;
width: 100%;
}
.hux-autonomy-matrix th,
.hux-autonomy-matrix td {
border-block-start: 1px solid rgb(255 255 255 / 8%);
padding: 0.5rem;
text-align: start;
}
.hux-autonomy-budgets > div {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.hux-autonomy-budgets label {
display: grid;
gap: 0.3rem;
}
.hux-autonomy input,
.hux-autonomy select,
.hux-autonomy button {
font: inherit;
}
.hux-autonomy input,
.hux-autonomy select {
background: var(--hermes-input, #0d1420);
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 0.4rem;
color: inherit;
padding: 0.45rem;
}
.hux-autonomy button {
border-radius: 0.5rem;
cursor: pointer;
padding: 0.55rem 0.8rem;
}
.hux-autonomy-stop {
align-items: center;
background: #9e2638;
border: 2px solid #ff6c7d;
color: white;
display: inline-flex;
font-weight: 700;
gap: 0.4rem;
}
.hux-autonomy-save {
justify-self: end;
}
.hux-autonomy-approvals ol {
display: grid;
gap: 0.75rem;
list-style: none;
padding: 0;
}
.hux-autonomy-approvals li {
background: rgb(255 255 255 / 4%);
border-inline-start: 3px solid var(--hermes-accent, #32d6d2);
padding: 0.75rem;
}
.hux-autonomy-approvals li[data-risk="high"] {
border-inline-start-color: #ff6c7d;
}
.hux-autonomy-approvals li > div {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-block-start: 0.65rem;
}
.hux-autonomy-warning {
color: #ffd479;
}
.hux-autonomy :focus-visible {
outline: 3px solid var(--hermes-focus, #71f7ef);
outline-offset: 2px;
}
.hux-autonomy button:disabled,
.hux-autonomy input:disabled,
.hux-autonomy select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 44rem) {
.hux-autonomy > header {
align-items: stretch;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-autonomy * {
scroll-behavior: auto;
}
}

View File

@ -0,0 +1,140 @@
/** Frontend-only contracts for default-off HUX-05 autonomy controls. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type AutonomyLevel = "ask_first" | "safe" | "autonomous";
export type GrantDecision = "allow" | "ask" | "deny";
export type ApprovalChoice = "once" | "session" | "always" | "deny";
export type Risk = "low" | "medium" | "high";
export type ScopeLevel = "global" | "project" | "conversation";
export type ScopeLimit = "current_task" | "conversation" | "project";
export type Capability =
| "read_files" | "write_files" | "shell" | "network" | "web_search"
| "send_message" | "memory_write" | "artifact_write" | "spend_tokens"
| "delegate" | "deploy" | "external_side_effect";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface PolicyScope {
level: ScopeLevel;
scopeId?: string;
}
export interface PolicyBudgets {
tokensPerRun: number;
toolCallsPerRun: number;
wallClockSeconds: number;
delegationsPerRun: number;
}
export interface PolicyView {
id: string;
owner: string;
scope: PolicyScope;
autonomy: AutonomyLevel;
grants: Readonly<Record<Capability, GrantDecision>>;
budgets: PolicyBudgets;
updatedAt: string;
}
export interface BudgetDraft extends PolicyBudgets {
/** Request guardrails; the backend must enforce these atomically. */
spendCentsPerRun: number;
scopeLimit: ScopeLimit;
}
export interface ApprovalView {
id: string;
identityKey: string;
runId: string;
conversationId: string;
capability: Capability;
summary: string;
risk: Risk;
requestedAt: string;
expiresAt: string;
status: "pending";
}
export interface RawAutonomyEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
conversation_id?: unknown;
policy?: unknown;
approvals?: unknown;
}
export interface AutonomyPage {
policy: PolicyView;
approvals: ApprovalView[];
rejectedApprovals: number;
}
export interface PolicyUpdateIntent {
expectedPolicyId: string;
expectedUpdatedAt: string;
identity: HuxIdentity;
scope: PolicyScope;
autonomy: AutonomyLevel;
grants: Readonly<Record<Capability, GrantDecision>>;
budgets: PolicyBudgets;
guardrails: {spendCentsPerRun: number; scopeLimit: ScopeLimit};
}
export interface ApprovalDecisionIntent {
approvalId: string;
expectedRunId: string;
expectedConversationId: string;
expectedCapability: Capability;
expectedRequestedAt: string;
choice: ApprovalChoice;
identity: HuxIdentity;
}
export interface ActiveRun {
runId: string;
conversationId: string;
tenantRef: string;
owner: string;
}
export interface StopIntent {
runId: string;
conversationId: string;
identity: HuxIdentity;
}
export interface StopReceiptView {
id: string;
runId: string;
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
stopped: number;
remaining: number;
effects: {description: string; reverted: boolean}[];
omittedEffects: number;
}
export interface AutonomyEndpoint {
method: "GET" | "PUT" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface AutonomyEndpointContract {
identity: HuxIdentity;
policy: AutonomyEndpoint;
updatePolicy: AutonomyEndpoint;
approvals: AutonomyEndpoint;
decideApproval(approvalId: string): AutonomyEndpoint;
stop(runId: string): AutonomyEndpoint;
release: {
schema: "hux.release.v1";
requiredState: "live_verified";
featureFlags: readonly string[];
};
}

View File

@ -0,0 +1,96 @@
.hux-workspace-toggle {
background: color-mix(in srgb, var(--bg-primary, #0a101b) 88%, #174653);
border: 1px solid rgb(94 231 215 / 45%);
border-radius: 999px;
bottom: max(5.8rem, calc(env(safe-area-inset-bottom) + 4.5rem));
box-shadow: 0 10px 32px rgb(0 0 0 / 32%);
color: var(--text-primary, #e7eef6);
cursor: pointer;
font: inherit;
padding: 0.55rem 0.9rem;
position: fixed;
right: max(1rem, env(safe-area-inset-right));
z-index: 115;
}
.hux-workspace-drawer {
background: color-mix(in srgb, var(--bg-primary, #0a101b) 97%, #102f3a);
border-left: 1px solid rgb(118 181 193 / 30%);
bottom: 0;
box-shadow: -18px 0 48px rgb(0 0 0 / 34%);
color: var(--text-primary, #e7eef6);
max-width: 100%;
overflow: auto;
position: fixed;
right: 0;
top: 0;
width: min(42rem, 94vw);
z-index: 160;
}
.hux-workspace-drawer[hidden] {
display: none !important;
}
.hux-workspace-drawer__header {
align-items: center;
backdrop-filter: blur(12px);
background: rgb(10 16 27 / 88%);
border-bottom: 1px solid rgb(118 181 193 / 24%);
display: flex;
justify-content: space-between;
padding: 0.8rem 1rem;
position: sticky;
top: 0;
z-index: 1;
}
.hux-workspace-drawer__header h2 {
font-size: 0.92rem;
margin: 0;
}
.hux-workspace-drawer__header button {
background: transparent;
border: 1px solid rgb(118 181 193 / 30%);
border-radius: 999px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0.4rem 0.7rem;
}
.hux-workspace-drawer__content {
display: grid;
gap: 0.8rem;
padding: 0.8rem;
}
.hux-workspace-wave {
min-width: 0;
}
.hux-workspace-toggle:focus-visible,
.hux-workspace-drawer button:focus-visible {
outline: 2px solid #5ee7d7;
outline-offset: 2px;
}
@media (max-width: 640px) {
.hux-workspace-drawer {
width: 100%;
}
}
@media (prefers-reduced-motion: no-preference) {
.hux-workspace-toggle {
transition: background-color 120ms ease, border-color 120ms ease;
}
}
@media (forced-colors: active) {
.hux-workspace-drawer,
.hux-workspace-toggle {
border-color: CanvasText;
}
}

View File

@ -0,0 +1,423 @@
/* node:coverage disable */
(function (root, factory) {
'use strict';
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else {
root.HermesHuxBootstrap = api;
api.autoStart(root);
}
}(typeof globalThis === 'object' ? globalThis : this, function () {
/* node:coverage enable */
'use strict';
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const SLOT = /^slot-[0-9]{1,3}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const WEBUI_SESSION = /^[A-Za-z0-9._:-]{4,160}$/;
const PROJECT_SOURCE = /^[A-Za-z0-9._:@+-]{1,200}$/;
const STREAM = /^[A-Za-z0-9._:-]{1,120}$/;
const TRUST = new Set(['router', 'relay', 'worker']);
const SURFACE = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
const CARD_FLAGS = Object.freeze({
'HUX-11': 'hux.foundation', 'HUX-01': 'hux.activity_timeline',
'HUX-02': 'hux.memory_control', 'HUX-03': 'hux.projects',
'HUX-04': 'hux.artifacts', 'HUX-05': 'hux.autonomy',
'HUX-06': 'hux.friendly_modes', 'HUX-07': 'hux.multimodal',
'HUX-08': 'hux.research', 'HUX-09': 'hux.onboarding',
'HUX-10': 'hux.privacy', 'HUX-12': 'hux.release_followthrough',
});
const REQUIRED_ROUTES = Object.freeze({
'HUX-11': [['HUX-11', '/hux/v1/capabilities'],
['HUX-11', '/hux/v1/context/bootstrap']],
'HUX-01': [['HUX-01', '/hux/v1/conversations/{id}/events']],
'HUX-02': [['HUX-02', '/hux/v1/memory'], ['HUX-02', '/hux/v1/memory/{id}/{action}']],
'HUX-03': [['HUX-03', '/hux/v1/projects/{id}'], ['HUX-03', '/hux/v1/conversations'],
['HUX-03', '/hux/v1/conversations/{id}'], ['HUX-03', '/hux/v1/conversations/{id}/lineage']],
'HUX-04': [['HUX-04', '/hux/v1/artifacts'], ['HUX-04', '/hux/v1/artifacts/{id}/versions'],
['HUX-04', '/hux/v1/artifacts/{id}/promote']],
'HUX-05': [['HUX-05', '/hux/v1/policy'], ['HUX-05', '/hux/v1/approvals'],
['HUX-05', '/hux/v1/approvals/{id}'], ['HUX-05', '/hux/v1/runs/{id}/stop'],
['HUX-05', '/hux/v1/runs/{id}/budget'], ['HUX-05', '/hux/v1/runs/{id}/gate']],
'HUX-06': [['HUX-06', '/hux/v1/modes'],
['HUX-06', '/hux/v1/projects/{project_id}/conversations/{id}/mode']],
'HUX-07': [['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items'],
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}'],
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections'],
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/capture-intents']],
'HUX-08': [['HUX-08', '/hux/v1/messages/{id}/citations'], ['HUX-08', '/hux/v1/notebooks'],
['HUX-08', '/hux/v1/notebooks/{id}']],
'HUX-09': [['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate'],
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions'],
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states']],
'HUX-10': [['HUX-10', '/hux/v1/privacy/policy'], ['HUX-10', '/hux/v1/privacy/notices'],
['HUX-10', '/hux/v1/conversations/{id}/forget'], ['HUX-10', '/hux/v1/privacy/audit']],
'HUX-12': [['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases'],
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}'],
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions']],
});
const WAVES = Object.freeze({
a: ['HUX-01', 'HUX-02'], b: ['HUX-03', 'HUX-04', 'HUX-06', 'HUX-08'],
governance: ['HUX-05', 'HUX-10'], c: ['HUX-07', 'HUX-09', 'HUX-12'],
});
function record(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}
function immutable(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(immutable);
return Object.freeze(value);
}
function opaque(value, prefix) {
return typeof value === 'string' && ID.test(value) && value.startsWith(`${prefix}_`);
}
function normalizeIdentity(value) {
if (!exact(value, ['tenant_slot', 'subject', 'surface', 'trust']) ||
!SLOT.test(value.tenant_slot) || !USER.test(value.subject) ||
!SURFACE.has(value.surface) || !TRUST.has(value.trust)) return null;
return immutable({tenantSlot: value.tenant_slot, userRef: value.subject,
surface: value.surface, trust: value.trust});
}
function normalizeContext(session) {
const value = record(session);
const context = value && record(value.hux_context);
const contextKeys = new Set(['schema', 'webui_session_id', 'session_id', 'conversation_id',
'project_id', 'project_source', 'identity', 'run_id', 'message_id',
'branch_point_message_id', 'notebook_id']);
if (!context || context.schema !== 'hux.webui_context.v1' ||
Object.keys(context).some((key) => !contextKeys.has(key)) ||
!WEBUI_SESSION.test(String(value.session_id || '')) ||
context.webui_session_id !== value.session_id || !opaque(context.session_id, 'ses') ||
!opaque(context.conversation_id, 'conv') || !opaque(context.project_id, 'prj') ||
!PROJECT_SOURCE.test(String(context.project_source || ''))) return null;
const identity = normalizeIdentity(context.identity);
if (!identity) return null;
const optional = {runId: context.run_id, messageId: context.message_id,
branchPointMessageId: context.branch_point_message_id, notebookId: context.notebook_id};
if (optional.runId !== undefined && !opaque(optional.runId, 'run')) return null;
for (const name of ['messageId', 'branchPointMessageId']) {
if (optional[name] !== undefined && !opaque(optional[name], 'msg')) return null;
}
if (optional.notebookId !== undefined && !opaque(optional.notebookId, 'nb')) return null;
return immutable({webuiSessionId: value.session_id, sessionId: context.session_id,
conversationId: context.conversation_id, projectId: context.project_id,
projectSource: context.project_source, identity, ...optional});
}
function sameIdentity(raw, expected) {
return Boolean(exact(raw, ['tenant_slot', 'subject', 'surface', 'trust']) &&
raw.tenant_slot === expected.tenantSlot &&
raw.subject === expected.userRef && raw.surface === expected.surface && raw.trust === expected.trust);
}
function exact(value, keys) {
return Boolean(record(value) && Object.keys(value).length === keys.length &&
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)));
}
function normalizeBootstrap(payload, context, status) {
const keys = ['schema', 'contract_version', 'identity', 'session_id', 'project_id',
'conversation_id', 'revisions', 'created'];
if (!exact(payload, keys) || payload.schema !== 'hux.context_bootstrap.v1' ||
payload.contract_version !== '1.1.0' || !sameIdentity(payload.identity, context.identity) ||
payload.session_id !== context.sessionId || payload.project_id !== context.projectId ||
payload.conversation_id !== context.conversationId ||
!exact(payload.revisions, ['project', 'conversation']) ||
!exact(payload.created, ['project', 'conversation'])) return null;
const revisions = payload.revisions; const created = payload.created;
if (!Number.isSafeInteger(revisions.project) || revisions.project < 1 ||
!Number.isSafeInteger(revisions.conversation) || revisions.conversation < 1 ||
typeof created.project !== 'boolean' || typeof created.conversation !== 'boolean' ||
(status === 201) !== (created.project || created.conversation)) return null;
return immutable({revisions: {...revisions}, created: {...created}});
}
function normalizeCapabilities(payload, expected) {
if (!exact(payload, ['schema', 'contract_version', 'identity', 'cards', 'server']) ||
payload.schema !== 'hux.capabilities.v1' ||
!/^1\.\d+\.\d+$/.test(String(payload.contract_version)) ||
!sameIdentity(payload.identity, expected) || !Array.isArray(payload.cards) ||
payload.cards.length > Object.keys(CARD_FLAGS).length || !record(payload.server) ||
Object.keys(payload.server).some((key) => !['commit', 'image_digest'].includes(key)) ||
Object.values(payload.server).some((item) => typeof item !== 'string' || item.length > 160)) return null;
const cards = {};
for (const item of payload.cards) {
if (!exact(item, ['card', 'flag', 'enabled', 'routes']) ||
!Object.prototype.hasOwnProperty.call(CARD_FLAGS, item.card) ||
CARD_FLAGS[item.card] !== item.flag || typeof item.enabled !== 'boolean' ||
!Array.isArray(item.routes) || cards[item.card]) return null;
const routes = item.routes.filter((route) => typeof route === 'string' &&
/^\/hux\/v1(?:\/[A-Za-z0-9._:{}-]+)*$/.test(route));
if (routes.length !== item.routes.length || new Set(routes).size !== routes.length) return null;
cards[item.card] = immutable({enabled: item.enabled, routes});
}
if (!cardComplete(cards, 'HUX-11')) return null;
return immutable(cards);
}
function cardComplete(cards, card) {
const current = cards[card];
if (!current || !current.enabled) return false;
return REQUIRED_ROUTES[card].every(([owner, route]) =>
cards[owner] && cards[owner].enabled && cards[owner].routes.includes(route));
}
function enabledWaves(cards) {
const result = [];
Object.entries(WAVES).forEach(([wave, members]) => {
const enabled = members.filter((card) => cards[card] && cards[card].enabled);
if (enabled.length && enabled.every((card) => cardComplete(cards, card))) result.push(wave);
});
return Object.freeze(result);
}
function safeCsrf(config) {
const value = record(config) && config.csrfToken;
return typeof value === 'string' && value.length >= 16 && value.length <= 512 &&
!/[\u0000-\u001f\u007f]/.test(value) ? value : null;
}
function scopedFetcher(fetcher, csrfToken) {
return function huxFetch(url, init) {
const target = String(url || '');
if (!/^\/hux\/v1(?:[/?]|$)/.test(target) || target.startsWith('//') || target.includes('..')) {
return Promise.reject(new TypeError('HUX browser requests must remain same-origin'));
}
const options = init || {};
const method = String(options.method || 'GET').toUpperCase();
const headers = {...(options.headers || {})};
if (!['GET', 'HEAD'].includes(method)) headers['X-Hermes-CSRF-Token'] = csrfToken;
return fetcher(target, {...options, cache: 'no-store', credentials: 'same-origin', headers});
};
}
async function preflight(fetcher, context) {
try {
const response = await fetcher('/hux/v1/capabilities', {method: 'GET',
headers: {Accept: ACCEPT}});
if (!response || response.status === 404 || !response.ok) return null;
const cards = normalizeCapabilities(await response.json(), context.identity);
const waves = cards && enabledWaves(cards);
return waves && waves.length ? immutable({cards, waves}) : null;
} catch (_) {
return null;
}
}
async function bootstrapContext(fetcher, context) {
try {
const response = await fetcher('/hux/v1/context/bootstrap', {method: 'POST', headers: {
Accept: ACCEPT, 'Content-Type': 'application/json',
'Idempotency-Key': `hux:context:${context.sessionId}`,
}, body: JSON.stringify({raw_session_id: context.webuiSessionId,
project_source: context.projectSource, session_id: context.sessionId,
conversation_id: context.conversationId, project_id: context.projectId})});
if (!response || ![200, 201].includes(response.status) || !response.ok) return null;
return normalizeBootstrap(await response.json(), context, response.status);
} catch (_) {
return null;
}
}
function node(doc, tag, attributes, text) {
const value = doc.createElement(tag);
Object.entries(attributes).forEach(([name, item]) => value.setAttribute(name, item));
if (text !== undefined) value.textContent = String(text);
return value;
}
function createChrome(doc) {
const open = node(doc, 'button', {'class': 'hux-workspace-toggle', type: 'button',
'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace');
const drawer = node(doc, 'aside', {id: 'huxWorkspaceDrawer', 'class': 'hux-workspace-drawer',
'aria-labelledby': 'huxWorkspaceTitle'});
const header = node(doc, 'header', {'class': 'hux-workspace-drawer__header'});
const title = node(doc, 'h2', {id: 'huxWorkspaceTitle'}, 'Hermes workspace');
const close = node(doc, 'button', {type: 'button', 'aria-label': 'Close Hermes workspace'}, 'Close');
const content = node(doc, 'div', {'class': 'hux-workspace-drawer__content'});
drawer.hidden = true;
header.appendChild(title); header.appendChild(close); drawer.appendChild(header); drawer.appendChild(content);
doc.body.appendChild(open); doc.body.appendChild(drawer);
function setOpen(value) {
drawer.hidden = !value; open.setAttribute('aria-expanded', value ? 'true' : 'false');
if (value && typeof close.focus === 'function') close.focus();
}
const onOpen = () => setOpen(true);
const onClose = () => setOpen(false);
const onKey = (event) => { if (event.key === 'Escape' && !drawer.hidden) setOpen(false); };
open.addEventListener('click', onOpen); close.addEventListener('click', onClose);
doc.addEventListener('keydown', onKey);
return Object.freeze({content, drawer, open, setOpen, destroy() {
doc.removeEventListener('keydown', onKey);
if (open.parentNode) open.parentNode.removeChild(open);
if (drawer.parentNode) drawer.parentNode.removeChild(drawer);
}});
}
async function defaultRuntimeFactory(root, target, context, fetcher, waves, controls) {
const runtimes = [];
const common = {document: root.document, fetcher, expectedIdentity: context.identity,
projectId: context.projectId, conversationId: context.conversationId,
sessionId: context.sessionId, runId: context.runId, messageId: context.messageId,
branchPointMessageId: context.branchPointMessageId, notebookId: context.notebookId,
stopModelResponse: controls && controls.stopModelResponse,
canStopModelResponse: controls && controls.canStopModelResponse};
const constructors = {
a: root.HermesHuxWaveA && root.HermesHuxWaveA.createWaveARuntime,
b: root.HermesHuxWaveBRuntime && root.HermesHuxWaveBRuntime.createWaveBRuntime,
governance: root.HermesHuxAutonomyPrivacy &&
root.HermesHuxAutonomyPrivacy.createAutonomyPrivacyRuntime,
c: root.HermesHuxWaveC && root.HermesHuxWaveC.createWaveCRuntime,
};
try {
for (const wave of waves) {
if (typeof constructors[wave] !== 'function') throw new Error('HUX runtime is incomplete');
const host = node(root.document, 'section', {'class': `hux-workspace-wave hux-workspace-wave--${wave}`});
target.appendChild(host);
const runtime = constructors[wave](common);
runtimes.push(runtime);
await runtime.mount(host);
}
} catch (error) {
runtimes.reverse().forEach((runtime) => runtime.destroy());
throw error;
}
return Object.freeze({destroy() { runtimes.reverse().forEach((runtime) => runtime.destroy()); }});
}
function ownedActiveStream(session, activeStreamId, expected) {
const current = normalizeContext(session);
const raw = record(session);
return current && expected && current.webuiSessionId === expected.webuiSessionId &&
current.conversationId === expected.conversationId &&
current.identity.userRef === expected.identity.userRef && STREAM.test(String(activeStreamId || '')) &&
raw.active_stream_id === activeStreamId ? activeStreamId : null;
}
function createOwnedStop(root, sessionReader, activeStreamReader, context, timeoutMs) {
const bounded = Number.isSafeInteger(timeoutMs) && timeoutMs >= 250 && timeoutMs <= 10000 ? timeoutMs : 3000;
const canStopModelResponse = () => Boolean(ownedActiveStream(sessionReader(), activeStreamReader(), context) &&
typeof root.cancelStream === 'function');
async function stopModelResponse() {
const streamId = ownedActiveStream(sessionReader(), activeStreamReader(), context);
if (!streamId || typeof root.cancelStream !== 'function') return Object.freeze({accepted: false});
let timer = null;
try {
const timeout = new Promise((resolve) => { timer = root.setTimeout(() => resolve(false), bounded); });
const accepted = await Promise.race([Promise.resolve(root.cancelStream('hux-stop')), timeout]);
if (timer !== null) root.clearTimeout(timer);
return Object.freeze({accepted: accepted === true, sessionId: context.webuiSessionId, streamId});
} catch (_) {
if (timer !== null) root.clearTimeout(timer);
return Object.freeze({accepted: false});
}
}
return Object.freeze({canStopModelResponse, stopModelResponse});
}
function createCoordinator(options) {
const settings = options || {};
const root = settings.root;
const doc = settings.document || (root && root.document);
const rawFetch = settings.fetcher || (root && root.fetch && root.fetch.bind(root));
const sessionReader = settings.sessionReader;
const activeStreamReader = settings.activeStreamReader || (() => browserActiveStreamReader());
const configReader = settings.configReader || (() => root.__HERMES_CONFIG__);
const runtimeFactory = settings.runtimeFactory || ((target, context, fetcher, waves, controls) =>
defaultRuntimeFactory(root, target, context, fetcher, waves, controls));
if (!root || !doc || !rawFetch || typeof sessionReader !== 'function') {
throw new TypeError('HUX coordinator requires browser, fetch, and trusted session reader');
}
let fingerprint = null; let pendingFingerprint = null; let generation = 0;
let chrome = null; let runtime = null; let timer = null;
function clear() {
if (runtime) runtime.destroy();
if (chrome) chrome.destroy();
runtime = null; chrome = null;
}
async function refresh() {
const context = normalizeContext(sessionReader());
const activeStream = context ? ownedActiveStream(sessionReader(), activeStreamReader(), context) : null;
const next = context ? `${context.webuiSessionId}\0${context.conversationId}\0${context.identity.userRef}` +
`\0${context.runId || ''}\0${activeStream || ''}` : '';
if (next === fingerprint || next === pendingFingerprint) return false;
pendingFingerprint = next; const current = ++generation; clear();
if (!context) { fingerprint = ''; pendingFingerprint = null; return false; }
const csrf = safeCsrf(configReader());
if (!csrf) { pendingFingerprint = null; return false; }
const fetcher = scopedFetcher(rawFetch, csrf);
const capability = await preflight(fetcher, context);
if (current !== generation) return false;
if (!capability) { pendingFingerprint = null; return false; }
const binding = await bootstrapContext(fetcher, context);
if (current !== generation) return false;
if (!binding) { pendingFingerprint = null; return false; }
const nextChrome = createChrome(doc);
try {
const controls = createOwnedStop(root, sessionReader, activeStreamReader, context, settings.stopTimeoutMs);
const nextRuntime = await runtimeFactory(nextChrome.content, context, fetcher, capability.waves, controls);
if (current !== generation) { nextRuntime.destroy(); nextChrome.destroy(); return false; }
chrome = nextChrome; runtime = nextRuntime; fingerprint = next;
pendingFingerprint = null; return true;
} catch (_) {
nextChrome.destroy(); pendingFingerprint = null; return false;
}
}
function start() {
if (timer !== null) return;
void refresh();
timer = root.setInterval(() => { void refresh(); }, settings.pollMs || 750);
root.addEventListener('pageshow', refresh);
}
function destroy() {
generation += 1; fingerprint = null; pendingFingerprint = null;
if (timer !== null) root.clearInterval(timer);
timer = null; root.removeEventListener('pageshow', refresh); clear();
}
return Object.freeze({destroy, refresh, start, getState: () => ({active: Boolean(runtime), fingerprint})});
}
function browserSessionReader() {
return typeof S === 'object' ? S.session : null;
}
function browserActiveStreamReader() {
return typeof S === 'object' ? S.activeStreamId : null;
}
function mergeTrustedContext(session, payload) {
const current = record(session); const response = record(payload);
if (!current || !response || response.session_id !== current.session_id ||
!record(response.hux_context)) return false;
const next = normalizeContext({...current, hux_context: response.hux_context});
if (!next || next.webuiSessionId !== current.session_id) return false;
current.hux_context = response.hux_context;
return true;
}
function autoStart(root) {
if (!root || !root.document || typeof root.fetch !== 'function') return null;
const coordinator = createCoordinator({root, sessionReader: browserSessionReader});
if (root.document.readyState === 'loading') {
root.document.addEventListener('DOMContentLoaded', () => coordinator.start(), {once: true});
} else coordinator.start();
return coordinator;
}
return Object.freeze({ACCEPT, autoStart, browserActiveStreamReader, cardComplete, createChrome,
createCoordinator, createOwnedStop, ownedActiveStream,
bootstrapContext, defaultRuntimeFactory, enabledWaves, normalizeBootstrap,
normalizeCapabilities, normalizeContext, normalizeIdentity, mergeTrustedContext,
preflight, safeCsrf, scopedFetcher});
}));

View File

@ -0,0 +1,75 @@
.hux-shell {
--hux-accent: #5ee7d7;
--hux-border: rgb(118 181 193 / 24%);
background: color-mix(in srgb, var(--bg-primary, #0a101b) 94%, #14323c);
border: 1px solid var(--hux-border);
border-radius: 14px;
color: var(--text-primary, #e7eef6);
min-width: 0;
}
.hux-shell[hidden] {
display: none !important;
}
.hux-shell__title {
font-size: 0.78rem;
letter-spacing: 0.09em;
margin: 0;
padding: 0.9rem 1rem 0.55rem;
text-transform: uppercase;
}
.hux-shell__tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
padding: 0 0.75rem 0.7rem;
}
.hux-shell__tab,
.hux-shell__retry {
background: transparent;
border: 1px solid var(--hux-border);
border-radius: 999px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0.42rem 0.7rem;
}
.hux-shell__tab[aria-selected='true'] {
background: rgb(94 231 215 / 12%);
border-color: var(--hux-accent);
}
.hux-shell__tab:focus-visible,
.hux-shell__retry:focus-visible {
outline: 2px solid var(--hux-accent);
outline-offset: 2px;
}
.hux-shell__body {
border-top: 1px solid var(--hux-border);
}
.hux-shell__panel {
min-width: 0;
padding: 1rem;
}
.hux-shell__status {
align-items: center;
display: flex;
gap: 0.75rem;
justify-content: space-between;
min-height: 3rem;
padding: 0.75rem 1rem;
}
@media (prefers-reduced-motion: no-preference) {
.hux-shell__tab,
.hux-shell__retry {
transition: background-color 120ms ease, border-color 120ms ease;
}
}

View File

@ -0,0 +1,271 @@
(function (root, factory) {
'use strict';
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxFoundation = api;
}(typeof globalThis === 'object' ? globalThis : this, function () {
'use strict';
const API_VERSION = 'hux.v1';
const CAPABILITY_SCHEMA = 'hux.capabilities.v1';
const FLAG_DEPENDENCIES = Object.freeze({
'hux.activity_timeline': ['hux.foundation'],
'hux.memory_control': ['hux.foundation', 'hux.privacy'],
'hux.projects': ['hux.foundation'],
'hux.artifacts': ['hux.foundation', 'hux.projects'],
'hux.autonomy': ['hux.foundation', 'hux.activity_timeline'],
'hux.friendly_modes': ['hux.foundation'],
'hux.multimodal': ['hux.foundation', 'hux.artifacts', 'hux.autonomy'],
'hux.research': ['hux.foundation', 'hux.friendly_modes'],
'hux.onboarding': ['hux.foundation', 'hux.projects', 'hux.friendly_modes'],
'hux.privacy': ['hux.foundation'],
'hux.foundation': [],
'hux.release_followthrough': [],
});
const FLAGS = Object.freeze(Object.keys(FLAG_DEPENDENCIES));
const SURFACES = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT_REF = /^tnt_[0-9a-f]{16,64}$/;
const USER_REF = /^usr_[0-9a-f]{16,64}$/;
const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?Z$/;
const EVENT_KINDS = new Set([
'message.user', 'message.assistant', 'decision.route', 'decision.plan',
'tool.call', 'tool.result', 'approval.requested', 'approval.resolved',
'memory.proposed', 'memory.committed', 'memory.forgotten', 'artifact.created',
'artifact.version', 'artifact.promoted', 'citation.attached', 'mode.changed',
'run.started', 'run.cancelled', 'run.completed', 'run.failed', 'privacy.notice',
'suggestion.shown', 'suggestion.dismissed', 'release.transition',
]);
const EVIDENCE_KINDS = new Set([
'message', 'tool_call', 'tool_result', 'artifact_version', 'source', 'passage',
'memory', 'approval', 'run', 'url', 'file', 'build', 'flux', 'pod',
]);
const SENSITIVITY = new Set(['public', 'personal', 'sensitive', 'restricted']);
class HuxContractError extends Error {
constructor(message) {
super(message);
this.name = 'HuxContractError';
}
}
function immutable(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(immutable);
return Object.freeze(value);
}
function requiredString(value, pattern, field) {
if (typeof value !== 'string' || !pattern.test(value)) {
throw new HuxContractError(`Invalid ${field}`);
}
return value;
}
function normalizeIdentity(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new HuxContractError('Missing scoped identity');
}
if (!SURFACES.has(value.surface)) throw new HuxContractError('Invalid identity surface');
return immutable({
tenantRef: requiredString(value.tenant_ref || value.tenantRef, TENANT_REF, 'tenant reference'),
userRef: requiredString(value.user_ref || value.userRef, USER_REF, 'user reference'),
surface: value.surface,
});
}
function sameIdentity(left, right) {
return left.tenantRef === right.tenantRef &&
left.userRef === right.userRef && left.surface === right.surface;
}
function dependenciesEnabled(flag, declared, seen) {
if (!declared.has(flag) || seen.has(flag)) return declared.has(flag);
const next = new Set(seen).add(flag);
return FLAG_DEPENDENCIES[flag].every((dependency) =>
dependenciesEnabled(dependency, declared, next));
}
function normalizeCapabilities(payload, expectedIdentity) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new HuxContractError('Capability response is not an object');
}
if (payload.schema !== CAPABILITY_SCHEMA || payload.api_version !== API_VERSION) {
throw new HuxContractError('Unsupported HUX capability version');
}
const actualIdentity = normalizeIdentity(payload.identity);
if (!sameIdentity(actualIdentity, expectedIdentity)) {
throw new HuxContractError('Capability identity does not match this surface');
}
if (!Array.isArray(payload.flags)) throw new HuxContractError('Capability flags are missing');
if (payload.flags.length > FLAGS.length * 2) throw new HuxContractError('Too many capability flags');
const declared = new Set(payload.flags.filter((flag) => FLAGS.includes(flag)));
const enabled = FLAGS.filter((flag) => dependenciesEnabled(flag, declared, new Set()));
return immutable({
schema: CAPABILITY_SCHEMA,
apiVersion: API_VERSION,
identity: actualIdentity,
flags: enabled,
});
}
function createClient(options) {
const settings = options || {};
const fetcher = Object.prototype.hasOwnProperty.call(settings, 'fetcher') ?
settings.fetcher : (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (!fetcher) throw new TypeError('A fetch implementation is required');
const expectedIdentity = normalizeIdentity(settings.expectedIdentity);
const baseUrl = String(settings.baseUrl || '/hux/v1').replace(/\/$/, '');
if (!baseUrl.startsWith('/') || baseUrl.startsWith('//') || /[?#\\]/.test(baseUrl) || baseUrl.includes('..')) {
throw new TypeError('HUX baseUrl must be same-origin');
}
let state = immutable({phase: 'disabled', capabilities: null, error: null});
let generation = 0;
const listeners = new Set();
function publish(next) {
state = immutable(next);
listeners.forEach((listener) => listener(state));
return state;
}
async function negotiate() {
const current = ++generation;
publish({phase: 'loading', capabilities: null, error: null});
try {
const response = await fetcher(`${baseUrl}/capabilities`, {
cache: 'no-store',
credentials: 'same-origin',
headers: {Accept: 'application/vnd.hermes.hux+json; version=1'},
});
if (current !== generation) return state;
if (response.status === 404) {
return publish({phase: 'disabled', capabilities: null, error: null});
}
if (!response.ok) throw new Error(`Capability request failed (${response.status})`);
const capabilities = normalizeCapabilities(await response.json(), expectedIdentity);
if (!capabilities.flags.includes('hux.foundation')) {
return publish({phase: 'disabled', capabilities: null, error: null});
}
return publish({phase: 'ready', capabilities, error: null});
} catch (error) {
if (current !== generation) return state;
const message = error instanceof HuxContractError ?
'Hermes workspace capabilities could not be verified.' :
'Hermes workspace features are temporarily unavailable.';
return publish({phase: 'error', capabilities: null, error: message});
}
}
function subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
listeners.add(listener);
listener(state);
return function unsubscribe() { listeners.delete(listener); };
}
return Object.freeze({
apiVersion: API_VERSION,
identity: expectedIdentity,
getState: () => state,
negotiate,
subscribe,
enabled: (flag) => state.phase === 'ready' &&
FLAGS.includes(flag) && state.capabilities.flags.includes(flag),
endpoint: (path) => {
const safe = String(path || '');
if (!safe.startsWith('/') || safe.startsWith('//') || safe.includes('..') ||
/\\|%(?:2e|2f|5c)/i.test(safe)) {
throw new TypeError('HUX endpoint must be a scoped relative path');
}
return baseUrl + safe;
},
});
}
function createAdapters(scope) {
const identity = normalizeIdentity(scope && scope.identity);
const conversationId = requiredString(scope && scope.conversationId, ID, 'conversation id');
function assertRecord(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
throw new HuxContractError('HUX record is not an object');
}
if (record.owner && record.owner !== identity.userRef) {
throw new HuxContractError('HUX record owner crossed the user boundary');
}
if (record.conversation_id && record.conversation_id !== conversationId) {
throw new HuxContractError('HUX record crossed the conversation boundary');
}
}
function adaptEvent(record) {
assertRecord(record);
if (record.schema !== 'hux.event.v1' || !Number.isSafeInteger(record.seq) || record.seq < 0) {
throw new HuxContractError('Invalid activity event');
}
requiredString(record.id, ID, 'event id');
requiredString(record.ts, TIMESTAMP, 'event timestamp');
if (!EVENT_KINDS.has(record.kind) || !SENSITIVITY.has(record.sensitivity)) {
throw new HuxContractError('Invalid activity event classification');
}
if (typeof record.summary !== 'string' || !record.summary.length || record.summary.length > 280) {
throw new HuxContractError('Invalid activity event summary');
}
if (!record.redaction || !['none', 'partial', 'full'].includes(record.redaction.level)) {
throw new HuxContractError('Invalid activity event redaction');
}
if (record.conversation_id !== conversationId) {
throw new HuxContractError('Activity event has no matching conversation');
}
const hidden = record.redaction && record.redaction.level === 'full';
const evidence = Array.isArray(record.evidence) ? record.evidence.slice(0, 64).map((item) => ({
kind: String(item && item.kind || ''),
id: String(item && item.id || ''),
})).filter((item) => EVIDENCE_KINDS.has(item.kind) && item.id.length <= 200) : [];
return immutable({
schema: record.schema,
id: record.id,
seq: record.seq,
timestamp: String(record.ts || ''),
kind: String(record.kind || ''),
summary: hidden ? 'Details hidden by privacy controls.' : String(record.summary || '').slice(0, 280),
sensitivity: String(record.sensitivity || 'personal'),
redacted: hidden,
evidence,
});
}
function adaptObject(record) {
assertRecord(record);
if (typeof record.schema !== 'string' || !/^hux\.[a-z_]+\.v1$/.test(record.schema)) {
throw new HuxContractError('Unsupported HUX object version');
}
requiredString(record.id, ID, 'object id');
return immutable({
schema: record.schema,
id: record.id,
title: String(record.title || record.question || record.kind || 'Hermes item').slice(0, 300),
kind: String(record.kind || record.type || ''),
status: String(record.status || ''),
sensitivity: String(record.sensitivity || 'personal'),
conversationId: record.conversation_id || null,
projectId: record.project_id || null,
updatedAt: record.updated_at || record.created_at || null,
});
}
return Object.freeze({identity, conversationId, adaptEvent, adaptObject});
}
return Object.freeze({
API_VERSION,
CAPABILITY_SCHEMA,
FLAGS,
HuxContractError,
createAdapters,
createClient,
normalizeCapabilities,
normalizeIdentity,
});
}));

View File

@ -0,0 +1,346 @@
/** Accessible HUX-02 ledger UI. No network request is made by this module. */
import { EyeOff, History, Pencil, ShieldCheck, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
MEMORY_TOPICS,
memoryControlEnabled,
normalizeMemoryPage,
} from "./model.ts";
import { isControlReference } from "./security.ts";
import type {
DoNotRememberTarget,
HuxIdentity,
MemoryItem,
MemoryTopic,
RawMemoryPage,
} from "./types.ts";
interface MemoryControlCenterProps {
flags?: Iterable<string>;
identity: HuxIdentity;
page: RawMemoryPage;
conversationId?: string;
messageId?: string;
busyId?: string;
onApprove?: (memoryId: string) => void;
onReject?: (memoryId: string) => void;
onEditAsNew?: (memoryId: string, content: string) => void;
onForget?: (memoryId: string) => void;
onOpenSourceMessage?: (memoryId: string) => void;
onDoNotRemember?: (target: DoNotRememberTarget) => void;
onRequireSuggestOnly?: () => void;
}
function LedgerActions({
item,
busy,
onApprove,
onReject,
onEdit,
onForget,
onOpenSource,
}: {
item: MemoryItem;
busy: boolean;
onApprove?: () => void;
onReject?: () => void;
onEdit?: () => void;
onForget?: () => void;
onOpenSource?: () => void;
}) {
if (item.status === "forgotten" || item.status === "expired") return null;
if (item.status === "proposed") {
return (
<div className="hux-memory-actions" aria-label="Approval choices">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
<button type="button" disabled={busy} onClick={onApprove}>
Remember
</button>
<button type="button" disabled={busy} onClick={onReject}>
Reject
</button>
</div>
);
}
return (
<div className="hux-memory-actions">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
{item.content !== null && (
<button type="button" disabled={busy} onClick={onEdit}>
<Pencil aria-hidden /> Edit as new entry
</button>
)}
<button type="button" disabled={busy} onClick={onForget}>
<Trash2 aria-hidden /> Delete &amp; forget
</button>
</div>
);
}
function MemoryCard({
item,
busy,
editing,
draft,
setDraft,
beginEdit,
cancelEdit,
saveEdit,
approve,
reject,
forget,
openSource,
}: {
item: MemoryItem;
busy: boolean;
editing: boolean;
draft: string;
setDraft: (value: string) => void;
beginEdit: () => void;
cancelEdit: () => void;
saveEdit: () => void;
approve: () => void;
reject: () => void;
forget: () => void;
openSource?: () => void;
}) {
return (
<li className="hux-memory-card" data-status={item.status}>
<header>
<span className="hux-memory-kind">{item.kind}</span>
<span className="hux-memory-status">{item.status}</span>
</header>
{item.content === null ? (
<p className="hux-memory-hidden">
<EyeOff aria-hidden /> {item.contentNotice}
</p>
) : (
<p className="hux-memory-content">{item.content}</p>
)}
<dl className="hux-memory-meta">
<div>
<dt>Why</dt><dd>{item.reason}</dd>
</div>
<div>
<dt>Source</dt><dd>{item.sourceLabel}</dd>
</div>
<div>
<dt>Recorded by</dt><dd>{item.provenanceLabel}</dd>
</div>
<div>
<dt>Scope</dt><dd>{item.scopeLabel}</dd>
</div>
<div>
<dt>Sensitivity</dt><dd>{item.sensitivity}</dd>
</div>
<div>
<dt>Approval</dt>
<dd>{item.approvalMode === "ask" ? "Ask first" : "Automatic (historical)"}</dd>
</div>
<div>
<dt>Retention</dt><dd>{item.ttlLabel}</dd>
</div>
<div>
<dt>Saved</dt>
<dd>
<time dateTime={item.savedAt}>
{new Date(item.savedAt).toLocaleString()}
</time>
</dd>
</div>
</dl>
{editing && (
<form
className="hux-memory-edit"
onSubmit={(event) => {
event.preventDefault();
saveEdit();
}}
>
<label htmlFor={`hux-memory-edit-${item.id}`}>Proposed replacement</label>
<textarea
id={`hux-memory-edit-${item.id}`}
value={draft}
maxLength={2000}
required
autoFocus
onChange={(event) => setDraft(event.target.value)}
/>
<p>
The original stays in the audit ledger. This replacement needs
approval.
</p>
<div className="hux-memory-actions">
<button type="submit" disabled={busy || !draft.trim()}>
Propose change
</button>
<button type="button" onClick={cancelEdit}>Cancel</button>
</div>
</form>
)}
{!editing && (
<LedgerActions
item={item}
busy={busy}
onApprove={approve}
onReject={reject}
onEdit={beginEdit}
onForget={forget}
onOpenSource={openSource}
/>
)}
</li>
);
}
/** Render nothing unless foundation, privacy, and memory-control are enabled. */
export function MemoryControlCenter(props: MemoryControlCenterProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [topic, setTopic] = useState<MemoryTopic>("general");
const enabled = memoryControlEnabled(props.flags);
const page = useMemo(
() => {
if (!enabled) return null;
try {
return normalizeMemoryPage(props.page, props.identity);
} catch {
return null;
}
},
[enabled, props.identity, props.page],
);
if (!enabled) return null;
if (!page) {
return (
<section className="hux-memory" role="alert">
Memory is unavailable because its workspace scope could not be verified.
</section>
);
}
function beginEdit(item: MemoryItem) {
setEditingId(item.id);
setDraft(item.content || "");
}
function saveEdit(item: MemoryItem) {
const value = draft.trim();
if (value) props.onEditAsNew?.(item.id, value);
setEditingId(null);
setDraft("");
}
return (
<section className="hux-memory" aria-labelledby="hux-memory-title">
<header className="hux-memory-heading">
<div>
<h2 id="hux-memory-title">
<History aria-hidden /> Memory
</h2>
<p>
An append-only record of what Hermes may use in future
conversations.
</p>
</div>
<div className="hux-memory-approval" role="status">
<ShieldCheck aria-hidden />
<span>Suggest only — nothing new is saved without your approval.</span>
<button type="button" onClick={props.onRequireSuggestOnly}>Enforce</button>
</div>
</header>
<fieldset className="hux-memory-blocks">
<legend>Do not remember</legend>
{isControlReference(props.messageId) && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "message",
messageId: props.messageId as string,
})
}
>
This message
</button>
)}
{isControlReference(props.conversationId, "conv") && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "conversation",
conversationId: props.conversationId as string,
})
}
>
This conversation
</button>
)}
<label htmlFor="hux-memory-topic">Topic</label>
<select
id="hux-memory-topic"
value={topic}
onChange={(event) => setTopic(event.target.value as MemoryTopic)}
>
{MEMORY_TOPICS.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
<button
type="button"
onClick={() => props.onDoNotRemember?.({ kind: "topic", topic })}
>
Block topic
</button>
</fieldset>
{(page.invalid > 0 || page.truncated > 0) && (
<p className="hux-memory-warning" role="status">
Some entries were withheld because they were unsafe, out of scope, or
beyond this page.
</p>
)}
<ol className="hux-memory-ledger" aria-label="Memory ledger">
{page.entries.map((item) => (
<MemoryCard
key={item.id}
item={item}
busy={props.busyId === item.id}
editing={editingId === item.id}
draft={draft}
setDraft={setDraft}
beginEdit={() => beginEdit(item)}
cancelEdit={() => {
setEditingId(null);
setDraft("");
}}
saveEdit={() => saveEdit(item)}
approve={() => props.onApprove?.(item.id)}
reject={() => props.onReject?.(item.id)}
forget={() => props.onForget?.(item.id)}
openSource={
props.onOpenSourceMessage
? () => props.onOpenSourceMessage?.(item.id)
: undefined
}
/>
))}
</ol>
{page.entries.length === 0 && (
<p className="hux-memory-empty">Hermes has no memory entries to show.</p>
)}
</section>
);
}

View File

@ -0,0 +1,79 @@
/**
* Same-origin endpoint descriptions for a future HUX-02 backend adapter.
* The server must derive identity from the authenticated session, compare it
* with each request's expectedIdentity, and echo it in list responses. A
* revision appends a proposed ledger entry; forget purges content while
* retaining audit metadata. These descriptors never perform a request.
*/
import { memoryControlEnabled } from "./model.ts";
import { isOpaqueMemoryId, normalizeIdentity } from "./security.ts";
import type {
HuxIdentity,
MemoryAction,
MemoryEndpoint,
MemoryEndpointContract,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(
method: MemoryEndpoint["method"],
path: string,
responseSchema: string,
): MemoryEndpoint {
return Object.freeze({ method, path, responseSchema });
}
/**
* Describe, but do not call, the backend interface. Returning null while any
* dependency flag is absent keeps HUX-02 inert and default-off.
*/
export function createMemoryEndpointContract(
client: FoundationClient,
flags?: Iterable<string>,
): MemoryEndpointContract | null {
if (!memoryControlEnabled(flags)) return null;
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
const identity = Object.freeze({ ...normalizeIdentity(client.identity) });
function itemPath(memoryId: string): string {
if (!isOpaqueMemoryId(memoryId)) throw new TypeError("Opaque memory id required");
return client.endpoint(`/memories/${memoryId}`);
}
return Object.freeze({
identity,
list: endpoint("GET", client.endpoint("/memories"), "hux.memory_page.v1"),
suggestOnly: endpoint(
"PUT",
client.endpoint("/memory-settings/suggest-only"),
"hux.memory_settings.v1",
),
doNotRemember: endpoint(
"POST",
client.endpoint("/memory-blocks"),
"hux.memory_block.v1",
),
action(memoryId: string, action: MemoryAction) {
if (!["approve", "reject", "forget"].includes(action))
throw new TypeError("Unsupported memory action");
return endpoint(
"POST",
`${itemPath(memoryId)}/${action}`,
"hux.memory.v1",
);
},
revise(memoryId: string) {
return endpoint(
"POST",
`${itemPath(memoryId)}/revisions`,
"hux.memory.v1",
);
},
});
}

View File

@ -0,0 +1,24 @@
export { MemoryControlCenter } from "./MemoryControlCenter.tsx";
export { createMemoryEndpointContract } from "./endpoints.ts";
export {
FOUNDATION_FLAG,
MEMORY_CONTROL_FLAG,
MEMORY_TOPICS,
PRIVACY_FLAG,
memoryControlEnabled,
normalizeMemoryPage,
} from "./model.ts";
export { MemoryContractError } from "./security.ts";
export type {
DoNotRememberTarget,
HuxIdentity,
MemoryBlockRequest,
MemoryEndpointContract,
MemoryItem,
MemoryMutationRequest,
MemoryPage,
MemoryRevisionRequest,
RawMemoryEntry,
RawMemoryPage,
SuggestOnlyRequest,
} from "./types.ts";

View File

@ -0,0 +1,239 @@
/** Fail-closed normalization for the append-only HUX-02 memory ledger. */
import {
MemoryContractError,
isOpaqueMemoryId,
isUtcTimestamp,
normalizeIdentity,
safeMemoryText,
sameIdentity,
} from "./security.ts";
import type {
HuxIdentity,
MemoryItem,
MemoryKind,
MemoryPage,
MemoryStatus,
MemoryTopic,
RawMemoryEntry,
RawMemoryPage,
Sensitivity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PRIVACY_FLAG = "hux.privacy";
export const MEMORY_CONTROL_FLAG = "hux.memory_control";
const KINDS = new Set<MemoryKind>([
"preference",
"fact",
"instruction",
"context",
]);
const STATUSES = new Set<MemoryStatus>([
"proposed",
"active",
"rejected",
"expired",
"forgotten",
]);
const SENSITIVITIES = new Set<Sensitivity>([
"public",
"personal",
"sensitive",
"restricted",
]);
export const MEMORY_TOPICS = [
"general",
"health",
"finance",
"legal",
"relationships",
"credentials",
"minors",
"location",
"biometric",
] as const satisfies readonly MemoryTopic[];
const TOPICS = new Set<MemoryTopic>(MEMORY_TOPICS);
const SOURCE_LABELS: Readonly<Record<string, string>> = {
message: "Source message",
tool_call: "Tool request",
tool_result: "Tool evidence",
artifact_version: "Artifact version",
source: "Research source",
passage: "Supporting passage",
memory: "Earlier memory entry",
approval: "Approval decision",
run: "Agent run",
url: "Website",
file: "Workspace file",
build: "Build evidence",
flux: "Flux reconciliation",
pod: "Workload evidence",
};
const MAX_ENTRIES = 200;
export function memoryControlEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const active = new Set(flags);
return (
active.has(FOUNDATION_FLAG) &&
active.has(PRIVACY_FLAG) &&
active.has(MEMORY_CONTROL_FLAG)
);
}
function ttlLabel(raw: unknown): string | null {
const ttl = raw as Record<string, unknown> | null;
if (!ttl || Array.isArray(ttl)) return null;
if (ttl.policy === "never") return "Kept until you forget it";
if (ttl.policy === "expires_at" && isUtcTimestamp(ttl.expires_at)) {
return `Expires ${ttl.expires_at}`;
}
if (
ttl.policy === "decay" &&
Number.isInteger(ttl.decay_days) &&
(ttl.decay_days as number) >= 1 &&
(ttl.decay_days as number) <= 3650
) {
return `Reviewed after ${ttl.decay_days} days`;
}
return null;
}
function scopeLabel(raw: unknown): string | null {
const scope = raw as Record<string, unknown> | null;
if (!scope || Array.isArray(scope)) return null;
if (scope.level === "global" && scope.scope_id === undefined)
return "All conversations";
if (scope.level === "project" && typeof scope.scope_id === "string")
return "This project";
if (scope.level === "conversation" && typeof scope.scope_id === "string")
return "This conversation";
return null;
}
function reasonFor(raw: RawMemoryEntry, hidden: boolean): string {
if (hidden) return "Reason hidden by privacy controls";
const audit = Array.isArray(raw.audit) ? raw.audit : [];
for (let index = audit.length - 1; index >= 0; index -= 1) {
const record = audit[index] as Record<string, unknown> | null;
if (record?.note) return safeMemoryText(record.note, "Saved for future help", 200);
}
return raw.status === "proposed"
? "Hermes suggested this for your approval"
: "Saved for future help";
}
function normalizeEntry(
raw: RawMemoryEntry,
expected: HuxIdentity,
): MemoryItem | null {
if (
raw.schema !== "hux.memory.v1" ||
!isOpaqueMemoryId(raw.id) ||
raw.owner !== expected.userRef ||
!KINDS.has(raw.kind as MemoryKind) ||
!STATUSES.has(raw.status as MemoryStatus) ||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) ||
!TOPICS.has((raw.topic ?? "general") as MemoryTopic) ||
!["automatic", "ask"].includes(String(raw.approval_mode)) ||
typeof raw.content !== "string" ||
raw.content.length > 2000 ||
!Array.isArray(raw.audit) ||
raw.audit.length < 1 ||
!isUtcTimestamp(raw.created_at) ||
!isUtcTimestamp(raw.updated_at)
) {
return null;
}
const provenance = raw.provenance as Record<string, unknown> | null;
const source = raw.source as Record<string, unknown> | null;
const ttl = ttlLabel(raw.ttl);
const scope = scopeLabel(raw.scope);
if (
!provenance ||
provenance.surface !== expected.surface ||
!isUtcTimestamp(provenance.recorded_at) ||
!source ||
typeof source.id !== "string" ||
source.id.length < 1 ||
source.id.length > 200 ||
typeof source.kind !== "string" ||
!(source.kind in SOURCE_LABELS) ||
!ttl ||
!scope
) {
return null;
}
const actor = provenance.actor as Record<string, unknown> | null;
if (
!actor ||
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
typeof actor.id !== "string" ||
actor.id.length < 1 ||
actor.id.length > 120
)
return null;
const status = raw.status as MemoryStatus;
const sensitivity = raw.sensitivity as Sensitivity;
const hidden =
status === "forgotten" ||
sensitivity === "sensitive" ||
sensitivity === "restricted";
return {
id: raw.id,
kind: raw.kind as MemoryKind,
content: hidden ? null : safeMemoryText(raw.content, "Memory content unavailable", 2000),
contentNotice:
status === "forgotten"
? "Content permanently removed"
: hidden
? "Sensitive value hidden by privacy controls"
: null,
status,
sensitivity,
topic: (raw.topic ?? "general") as MemoryTopic,
approvalMode: raw.approval_mode as "automatic" | "ask",
scopeLabel: scope,
sourceLabel: SOURCE_LABELS[source.kind],
sourceIsMessage: source.kind === "message",
provenanceLabel: `${String(actor.type)} via ${expected.surface}`,
reason: reasonFor(raw, hidden),
savedAt: raw.created_at,
updatedAt: raw.updated_at,
ttlLabel: ttl,
};
}
export function normalizeMemoryPage(
raw: RawMemoryPage,
expectedIdentity: HuxIdentity,
): MemoryPage {
const expected = normalizeIdentity(expectedIdentity);
if (!raw || typeof raw !== "object" || raw.schema !== "hux.memory_page.v1") {
throw new MemoryContractError("Memory response version is unsupported");
}
const actual = normalizeIdentity(raw.identity);
if (!sameIdentity(actual, expected)) {
throw new MemoryContractError("Memory response crossed a scope boundary");
}
if (!Array.isArray(raw.entries)) {
throw new MemoryContractError("Memory ledger entries are missing");
}
const records = raw.entries;
const normalized = records
.map((entry) => normalizeEntry(entry as RawMemoryEntry, expected))
.filter((entry): entry is MemoryItem => entry !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const truncated = Math.max(0, normalized.length - MAX_ENTRIES);
return {
entries: truncated ? normalized.slice(0, MAX_ENTRIES) : normalized,
invalid: records.length - normalized.length,
truncated,
nextCursor:
typeof raw.next_cursor === "string" && raw.next_cursor.length <= 200
? raw.next_cursor
: null,
};
}

View File

@ -0,0 +1,96 @@
/** HUX-02 display and scope validation. Raw payloads never cross this boundary. */
import type { HuxIdentity, HuxSurface } from "./types.ts";
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const AUTH =
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
const PEM = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
const BLOB =
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
const SURFACES = new Set<HuxSurface>([
"chat",
"worker",
"telegram",
"voice",
"api",
]);
export class MemoryContractError extends Error {
constructor(message: string) {
super(message);
this.name = "MemoryContractError";
}
}
export function safeMemoryText(
value: unknown,
fallback: string,
maxLength = 280,
): string {
if (typeof value !== "string") return fallback;
const limit = Math.max(1, maxLength);
const safe = value
.replace(PEM, "[redacted credential]")
.replace(AUTH, "$1=[redacted]")
.replace(BEARER, "Bearer [redacted]")
.replace(JWT, "[redacted credential]")
.replace(URI_USERINFO, "$1[redacted]@")
.replace(BLOB, "[redacted credential]")
.replace(CONTROL, " ")
.replace(/\s+/g, " ")
.trim();
if (!safe) return fallback;
if (safe.length <= limit) return safe;
return limit === 1 ? "…" : `${safe.slice(0, limit - 1).trimEnd()}…`;
}
export function isOpaqueMemoryId(value: unknown): value is string {
return typeof value === "string" && /^mem_[A-Za-z0-9._-]{4,80}$/.test(value);
}
export function isControlReference(
value: unknown,
prefix?: "conv",
): value is string {
if (typeof value !== "string") return false;
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value)) return false;
return !prefix || value.startsWith(`${prefix}_`);
}
export function isUtcTimestamp(value: unknown): value is string {
return (
typeof value === "string" &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
!Number.isNaN(Date.parse(value))
);
}
export function normalizeIdentity(value: unknown): HuxIdentity {
const raw = value as Record<string, unknown> | null;
const tenantRef = raw?.tenant_ref ?? raw?.tenantRef;
const userRef = raw?.user_ref ?? raw?.userRef;
if (
!raw ||
Array.isArray(raw) ||
typeof tenantRef !== "string" ||
!/^tnt_[0-9a-f]{16,64}$/.test(tenantRef) ||
typeof userRef !== "string" ||
!/^usr_[0-9a-f]{16,64}$/.test(userRef) ||
!SURFACES.has(raw.surface as HuxSurface)
) {
throw new MemoryContractError("Memory identity could not be verified");
}
return { tenantRef, userRef, surface: raw.surface as HuxSurface };
}
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
return (
left.tenantRef === right.tenantRef &&
left.userRef === right.userRef &&
left.surface === right.surface
);
}

View File

@ -0,0 +1,197 @@
.hux-memory {
color: var(--hux-text, #dce8f4);
display: grid;
gap: 1rem;
}
.hux-memory-heading {
align-items: start;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: space-between;
}
.hux-memory-heading h2,
.hux-memory-heading h2 svg,
.hux-memory-hidden,
.hux-memory-actions,
.hux-memory-approval {
align-items: center;
display: flex;
}
.hux-memory-heading h2 {
gap: 0.4rem;
margin: 0;
}
.hux-memory-heading p,
.hux-memory-edit p {
color: color-mix(in srgb, currentColor 62%, transparent);
font-size: 0.72rem;
margin: 0.25rem 0 0;
}
.hux-memory-approval {
background: color-mix(in srgb, #25d4c3 10%, transparent);
border: 1px solid color-mix(in srgb, #25d4c3 35%, transparent);
border-radius: 0.75rem;
font-size: 0.72rem;
gap: 0.4rem;
max-width: 24rem;
padding: 0.55rem 0.65rem;
}
.hux-memory-blocks {
align-items: center;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.65rem;
}
.hux-memory-blocks legend {
font-size: 0.72rem;
font-weight: 700;
padding: 0 0.3rem;
}
.hux-memory button,
.hux-memory select,
.hux-memory textarea {
background: color-mix(in srgb, currentColor 7%, transparent);
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.5rem;
color: inherit;
font: inherit;
}
.hux-memory button {
cursor: pointer;
padding: 0.38rem 0.55rem;
}
.hux-memory button:focus-visible,
.hux-memory select:focus-visible,
.hux-memory textarea:focus-visible {
outline: 2px solid var(--hux-accent, #25d4c3);
outline-offset: 2px;
}
.hux-memory button:disabled {
cursor: wait;
opacity: 0.55;
}
.hux-memory-ledger {
display: grid;
gap: 0.65rem;
list-style: none;
margin: 0;
padding: 0;
}
.hux-memory-card {
background: color-mix(in srgb, currentColor 3.5%, transparent);
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
border-radius: 0.8rem;
padding: 0.8rem;
}
.hux-memory-card > header {
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.hux-memory-kind,
.hux-memory-status {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hux-memory-status {
color: var(--hux-accent, #25d4c3);
}
.hux-memory-content {
font-size: 0.86rem;
line-height: 1.55;
margin: 0.65rem 0;
white-space: pre-wrap;
}
.hux-memory-hidden {
color: color-mix(in srgb, currentColor 62%, transparent);
font-size: 0.78rem;
gap: 0.4rem;
}
.hux-memory-meta {
display: grid;
font-size: 0.68rem;
gap: 0.35rem 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
margin: 0.75rem 0;
}
.hux-memory-meta div { min-width: 0; }
.hux-memory-meta dt { color: color-mix(in srgb, currentColor 52%, transparent); }
.hux-memory-meta dd { margin: 0.12rem 0 0; overflow-wrap: anywhere; }
.hux-memory-actions {
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
}
.hux-memory-actions svg,
.hux-memory-heading svg,
.hux-memory-hidden svg,
.hux-memory-approval svg {
height: 0.9rem;
width: 0.9rem;
}
.hux-memory-edit {
display: grid;
gap: 0.4rem;
}
.hux-memory-edit textarea {
min-height: 6rem;
padding: 0.55rem;
resize: vertical;
width: 100%;
}
.hux-memory-warning {
border-left: 2px solid #efb756;
color: #efcf96;
font-size: 0.72rem;
margin: 0;
padding-left: 0.6rem;
}
.hux-memory-empty {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.78rem;
text-align: center;
}
@media (max-width: 34rem) {
.hux-memory-heading,
.hux-memory-approval { align-items: stretch; }
.hux-memory-approval { max-width: none; width: 100%; }
.hux-memory-meta { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.hux-memory * { scroll-behavior: auto; transition: none; }
}

View File

@ -0,0 +1,126 @@
/** Safe frontend contracts for the default-off HUX-02 memory ledger. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type MemoryKind = "preference" | "fact" | "instruction" | "context";
export type MemoryStatus =
| "proposed"
| "active"
| "rejected"
| "expired"
| "forgotten";
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
export type MemoryTopic =
| "general"
| "health"
| "finance"
| "legal"
| "relationships"
| "credentials"
| "minors"
| "location"
| "biometric";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawMemoryEntry {
schema?: unknown;
id?: unknown;
owner?: unknown;
scope?: unknown;
kind?: unknown;
content?: unknown;
status?: unknown;
approval_mode?: unknown;
sensitivity?: unknown;
topic?: unknown;
ttl?: unknown;
source?: unknown;
provenance?: unknown;
created_at?: unknown;
updated_at?: unknown;
audit?: unknown;
}
export interface RawMemoryPage {
schema?: unknown;
identity?: unknown;
entries?: unknown;
next_cursor?: unknown;
}
export interface MemoryItem {
id: string;
kind: MemoryKind;
content: string | null;
contentNotice: string | null;
status: MemoryStatus;
sensitivity: Sensitivity;
topic: MemoryTopic;
approvalMode: "automatic" | "ask";
scopeLabel: string;
sourceLabel: string;
sourceIsMessage: boolean;
provenanceLabel: string;
reason: string;
savedAt: string;
updatedAt: string;
ttlLabel: string;
}
export interface MemoryPage {
entries: MemoryItem[];
invalid: number;
truncated: number;
nextCursor: string | null;
}
export type DoNotRememberTarget =
| { kind: "message"; messageId: string }
| { kind: "conversation"; conversationId: string }
| { kind: "topic"; topic: MemoryTopic };
export type MemoryAction = "approve" | "reject" | "forget";
export interface MemoryEndpoint {
method: "GET" | "POST" | "PUT";
path: string;
responseSchema: string;
}
export interface MemoryEndpointContract {
identity: HuxIdentity;
list: MemoryEndpoint;
suggestOnly: MemoryEndpoint;
doNotRemember: MemoryEndpoint;
action(memoryId: string, action: MemoryAction): MemoryEndpoint;
revise(memoryId: string): MemoryEndpoint;
}
/** The server compares expectedIdentity with the authenticated HUX capability scope. */
export interface MemoryMutationRequest {
expectedIdentity: HuxIdentity;
expectedUpdatedAt: string;
action: MemoryAction;
}
/** A revision appends a proposed entry; it never overwrites the referenced entry. */
export interface MemoryRevisionRequest {
expectedIdentity: HuxIdentity;
expectedUpdatedAt: string;
content: string;
approvalMode: "ask";
}
export interface MemoryBlockRequest {
expectedIdentity: HuxIdentity;
target: DoNotRememberTarget;
}
export interface SuggestOnlyRequest {
expectedIdentity: HuxIdentity;
approvalMode: "ask";
}

View File

@ -0,0 +1,187 @@
/** Accessible HUX-06 mode intent selector. The feature is default-off. */
import { useMemo } from "react";
import {
buildModeRequest,
friendlyModesEnabled,
normalizeAdvancedRoutes,
normalizeModeCatalog,
normalizeResolvedRoute,
} from "./model.ts";
import type {
FriendlyModeIntent,
FriendlyModeName,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
} from "./types.ts";
interface FriendlyModeSelectorProps {
flags?: Iterable<string>;
modes: readonly RawModeIntent[];
selectedMode: FriendlyModeName;
advancedRoutes?: readonly RawAdvancedRouteChoice[];
resolvedRoute?: RawResolvedRoute;
disabled?: boolean;
onChange(intent: FriendlyModeIntent): void;
}
const MODE_HELP: Readonly<Record<FriendlyModeName, string>> = {
fast: "Quick, direct answers with light reasoning.",
thoughtful: "More reasoning for careful decisions.",
research: "Source-backed investigation with citations.",
create: "Generative work with artifact support.",
private: "Local-only processing with ephemeral retention.",
};
const PROVIDER_LABELS = {
codex: "OpenAI",
claude: "Anthropic",
local: "Local",
} as const;
/** Render nothing unless HUX-11 and HUX-06 are both explicitly enabled. */
export function FriendlyModeSelector({
flags,
modes,
selectedMode,
advancedRoutes = [],
resolvedRoute,
disabled = false,
onChange,
}: FriendlyModeSelectorProps) {
const enabled = friendlyModesEnabled(flags);
const catalog = useMemo(() => normalizeModeCatalog(modes), [modes]);
const choices = useMemo(
() => normalizeAdvancedRoutes(advancedRoutes),
[advancedRoutes],
);
const resolved = useMemo(
() => normalizeResolvedRoute(resolvedRoute),
[resolvedRoute],
);
if (!enabled) return null;
if (!catalog) {
return (
<p className="hux-modes-error" role="alert">
Mode choices are unavailable because their safety contract could not be
verified.
</p>
);
}
const selected = catalog.find((mode) => mode.mode === selectedMode);
if (!selected) {
return (
<p className="hux-modes-error" role="alert">
The selected mode could not be verified. No routing change was made.
</p>
);
}
const compatibleChoices = choices.filter((choice) =>
selected.mode === "private"
? choice.localOnly && choice.provider === "local"
: !choice.localOnly && selected.constraints.providers.includes(choice.provider),
);
return (
<section className="hux-modes" aria-labelledby="hux-modes-heading">
<div className="hux-modes-heading">
<div>
<h2 id="hux-modes-heading">Response mode</h2>
<p>Choose the intent; Switchyard selects from the allowed pool.</p>
</div>
<span className="hux-modes-active" aria-live="polite">
{selected.label}
</span>
</div>
<fieldset disabled={disabled} className="hux-mode-grid">
<legend className="sr-only">Choose a response mode</legend>
{catalog.map((mode) => (
<label className="hux-mode-card" key={mode.mode}>
<input
type="radio"
name="hermes-friendly-mode"
value={mode.mode}
checked={mode.mode === selected.mode}
aria-describedby={`hux-mode-help-${mode.mode}`}
onChange={() => {
const request = buildModeRequest(mode);
if (request) onChange(request);
}}
/>
<span className="hux-mode-card-copy">
<strong>{mode.label}</strong>
<span id={`hux-mode-help-${mode.mode}`}>{MODE_HELP[mode.mode]}</span>
</span>
</label>
))}
</fieldset>
<details className="hux-modes-advanced">
<summary>Advanced routing details</summary>
<div className="hux-modes-advanced-panel">
<dl>
<div>
<dt>Allowed pools</dt>
<dd>
{selected.constraints.providers
.map((provider) => PROVIDER_LABELS[provider])
.join(" + ")}
</dd>
</div>
<div>
<dt>Requested route</dt>
<dd>{selected.switchyard.route_id}</dd>
</div>
{resolved?.target && (
<div>
<dt>Resolved route</dt>
<dd>{resolved.target}</dd>
</div>
)}
{resolved?.provider && (
<div>
<dt>Resolved provider</dt>
<dd>{PROVIDER_LABELS[resolved.provider]}</dd>
</div>
)}
{resolved?.effort && (
<div>
<dt>Resolved effort</dt>
<dd>{resolved.effort}</dd>
</div>
)}
</dl>
<label className="hux-route-pin">
<span>Exact route pin</span>
<select
disabled={disabled}
defaultValue=""
onChange={(event) => {
const request = event.currentTarget.value
? buildModeRequest(selected, event.currentTarget.value, choices)
: buildModeRequest(selected);
if (request) onChange(request);
}}
>
<option value="">Automatic — use all allowed pools</option>
{compatibleChoices.map((choice) => (
<option key={choice.routeId} value={choice.routeId}>
{choice.label} — {PROVIDER_LABELS[choice.provider]}
</option>
))}
</select>
</label>
<p className="hux-route-pin-help">
Pinning bypasses automatic selection for this choice. Automatic
modes otherwise keep both OpenAI and Anthropic eligible.
</p>
</div>
</details>
</section>
);
}

View File

@ -0,0 +1,26 @@
/** Public integration surface for the inert HUX-06 frontend module. */
export { FriendlyModeSelector } from "./FriendlyModeSelector.tsx";
export {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
MODE_ORDER,
buildModeRequest,
createFriendlyModesAdapter,
friendlyModesEnabled,
normalizeAdvancedRoutes,
normalizeMode,
normalizeModeCatalog,
normalizeResolvedRoute,
} from "./model.ts";
export type {
AdvancedRouteChoice,
FriendlyModeIntent,
FriendlyModeName,
FriendlyModesAdapter,
HuxFoundationClient,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
ResolvedRouteDisclosure,
} from "./types.ts";

View File

@ -0,0 +1,312 @@
/** Fail-closed HUX-06 intent validation and HUX foundation adapter. */
import type {
AdvancedRouteChoice,
FriendlyModeIntent,
FriendlyModeName,
FriendlyModesAdapter,
HuxFoundationClient,
ModeConstraints,
ModeEffort,
ModeProvider,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
ResolvedRouteDisclosure,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
export const MODE_ORDER: readonly FriendlyModeName[] = [
"fast",
"thoughtful",
"research",
"create",
"private",
];
const MODE_LABELS: Readonly<Record<FriendlyModeName, string>> = {
fast: "Fast",
thoughtful: "Thoughtful",
research: "Research",
create: "Create",
private: "Private",
};
const PROVIDERS = new Set<ModeProvider>(["codex", "claude", "local"]);
const EFFORTS: readonly ModeEffort[] = ["low", "medium", "high", "xhigh"];
const ROUTE = /^atlas\/(auto|manual|fallback|worker)\/[a-z0-9/-]+$/;
const MANUAL_ROUTE = /^atlas\/manual\/[a-z0-9/-]+$/;
const REMOTE_PROVIDER_NAME = /\b(?:anthropic|claude|codex|openai)\b/i;
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key))
);
}
function boundedString(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max;
}
function oneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
return typeof value === "string" && values.includes(value as T);
}
function normalizeConstraints(value: unknown): ModeConstraints | null {
const constraints = record(value);
const required = [
"providers",
"local_only",
"effort",
"tools",
"memory",
"citations_required",
"retention",
];
if (!constraints || !exactKeys(constraints, required)) return null;
if (!Array.isArray(constraints.providers) || constraints.providers.length < 1)
return null;
const providers = constraints.providers;
if (
providers.some((provider) => !PROVIDERS.has(provider as ModeProvider)) ||
new Set(providers).size !== providers.length ||
typeof constraints.local_only !== "boolean"
)
return null;
const effort = record(constraints.effort);
const tools = record(constraints.tools);
const memory = record(constraints.memory);
if (
!effort ||
!exactKeys(effort, ["min", "max"]) ||
!oneOf(effort.min, EFFORTS) ||
!oneOf(effort.max, EFFORTS) ||
EFFORTS.indexOf(effort.min) > EFFORTS.indexOf(effort.max)
)
return null;
if (
!tools ||
!exactKeys(tools, ["web", "shell", "artifacts", "delegate"]) ||
!oneOf(tools.web, ["required", "allowed", "denied"] as const) ||
!oneOf(tools.shell, ["allowed", "denied"] as const) ||
!oneOf(tools.artifacts, ["encouraged", "allowed", "denied"] as const) ||
!oneOf(tools.delegate, ["allowed", "denied"] as const)
)
return null;
if (
!memory ||
!exactKeys(memory, ["read", "write"]) ||
typeof memory.read !== "boolean" ||
typeof memory.write !== "boolean" ||
typeof constraints.citations_required !== "boolean" ||
!oneOf(constraints.retention, ["default", "ephemeral"] as const)
)
return null;
// Provider arrays are semantically sets. Canonical ordering prevents their
// transport order from becoming an accidental routing preference.
const canonicalProviders = (["codex", "claude", "local"] as const).filter(
(provider) => providers.includes(provider),
);
return {
providers: [...canonicalProviders],
local_only: constraints.local_only,
effort: { min: effort.min, max: effort.max },
tools: {
web: tools.web,
shell: tools.shell,
artifacts: tools.artifacts,
delegate: tools.delegate,
},
memory: { read: memory.read, write: memory.write },
citations_required: constraints.citations_required,
retention: constraints.retention,
};
}
function modeSemanticsAreSafe(
mode: FriendlyModeName,
constraints: ModeConstraints,
routeId: string,
): boolean {
const providers = new Set(constraints.providers);
if (mode === "private") {
return (
constraints.local_only &&
providers.size === 1 &&
providers.has("local") &&
routeId.startsWith("atlas/auto/")
);
}
return (
!constraints.local_only &&
providers.has("codex") &&
providers.has("claude") &&
!REMOTE_PROVIDER_NAME.test(routeId.replaceAll("/", " ")) &&
routeId.startsWith("atlas/auto/")
);
}
export function friendlyModesEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(FRIENDLY_MODES_FLAG);
}
export function normalizeMode(raw: RawModeIntent): FriendlyModeIntent | null {
const root = record(raw);
if (
!root ||
!exactKeys(root, ["schema", "mode", "label", "intent", "constraints", "switchyard"]) ||
root.schema !== "hux.mode.v1" ||
!oneOf(root.mode, MODE_ORDER) ||
root.label !== MODE_LABELS[root.mode] ||
!boundedString(root.intent, 1, 400) ||
REMOTE_PROVIDER_NAME.test(root.intent)
)
return null;
const constraints = normalizeConstraints(root.constraints);
const switchyard = record(root.switchyard);
if (
!constraints ||
!switchyard ||
!exactKeys(switchyard, ["route_id"]) ||
typeof switchyard.route_id !== "string" ||
!ROUTE.test(switchyard.route_id) ||
!modeSemanticsAreSafe(root.mode, constraints, switchyard.route_id)
)
return null;
return {
schema: "hux.mode.v1",
mode: root.mode,
label: root.label,
intent: root.intent,
constraints,
switchyard: { route_id: switchyard.route_id },
};
}
export function normalizeModeCatalog(raw: unknown): FriendlyModeIntent[] | null {
if (!Array.isArray(raw) || raw.length !== MODE_ORDER.length) return null;
const modes = raw.map((value) => normalizeMode(value as RawModeIntent));
if (modes.some((mode) => mode === null)) return null;
const byName = new Map(
(modes as FriendlyModeIntent[]).map((mode) => [mode.mode, mode]),
);
if (byName.size !== MODE_ORDER.length) return null;
return MODE_ORDER.map((name) => byName.get(name) as FriendlyModeIntent);
}
export function normalizeAdvancedRoutes(raw: unknown): AdvancedRouteChoice[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const choices: AdvancedRouteChoice[] = [];
for (const value of raw.slice(0, 100)) {
const choice = record(value as RawAdvancedRouteChoice);
if (
!choice ||
!exactKeys(choice, ["route_id", "label", "provider", "effort", "local_only"]) ||
typeof choice.route_id !== "string" ||
!MANUAL_ROUTE.test(choice.route_id) ||
seen.has(choice.route_id) ||
!boundedString(choice.label, 1, 80) ||
!PROVIDERS.has(choice.provider as ModeProvider) ||
!oneOf(choice.effort, EFFORTS) ||
typeof choice.local_only !== "boolean"
)
continue;
seen.add(choice.route_id);
choices.push({
routeId: choice.route_id,
label: choice.label,
provider: choice.provider as ModeProvider,
effort: choice.effort,
localOnly: choice.local_only,
});
}
return choices;
}
export function buildModeRequest(
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices: readonly AdvancedRouteChoice[] = [],
): FriendlyModeIntent | null {
const normalized = normalizeMode(mode);
if (!normalized) return null;
if (advancedRouteId === undefined) return normalized;
const choice = choices.find((item) => item.routeId === advancedRouteId);
if (!choice || !MANUAL_ROUTE.test(advancedRouteId)) return null;
const allowed = normalized.constraints.providers.includes(choice.provider);
const privateSafe =
normalized.mode !== "private" ||
(choice.localOnly && choice.provider === "local");
const automaticSafe =
normalized.mode === "private" || (!choice.localOnly && allowed);
if (!allowed || !privateSafe || !automaticSafe) return null;
return {
...normalized,
switchyard: {
route_id: normalized.switchyard.route_id,
override_route_id: advancedRouteId,
},
};
}
export function normalizeResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null {
const route = record(raw as RawResolvedRoute);
if (!route || !exactKeys(route, ["requested"], ["resolved_target", "provider", "effort"]))
return null;
if (!boundedString(route.requested, 1, 120)) return null;
if (
route.resolved_target !== undefined &&
!boundedString(route.resolved_target, 1, 120)
)
return null;
if (route.provider !== undefined && !PROVIDERS.has(route.provider as ModeProvider))
return null;
if (route.effort !== undefined && !oneOf(route.effort, EFFORTS)) return null;
return {
requested: route.requested,
target: route.resolved_target as string | undefined,
provider: route.provider as ModeProvider | undefined,
effort: route.effort as ModeEffort | undefined,
};
}
export function createFriendlyModesAdapter(
foundation: HuxFoundationClient,
): FriendlyModesAdapter {
if (
!foundation ||
foundation.apiVersion !== "hux.v1" ||
typeof foundation.enabled !== "function" ||
typeof foundation.endpoint !== "function"
)
throw new TypeError("A hux.v1 foundation client is required");
return Object.freeze({
apiVersion: "hux.v1" as const,
enabled: () =>
foundation.enabled(FOUNDATION_FLAG) && foundation.enabled(FRIENDLY_MODES_FLAG),
endpoint: () => foundation.endpoint("/modes"),
adaptCatalog: normalizeModeCatalog,
adaptResolvedRoute: normalizeResolvedRoute,
buildRequest: (
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices?: readonly AdvancedRouteChoice[],
) => buildModeRequest(mode, advancedRouteId, choices),
});
}

View File

@ -0,0 +1,178 @@
/* Scoped, inert-until-imported HUX-06 presentation. */
.hux-modes {
color: inherit;
container-type: inline-size;
}
.hux-modes-heading {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.hux-modes-heading h2 {
font-size: 0.875rem;
font-weight: 650;
margin: 0;
}
.hux-modes-heading p,
.hux-route-pin-help {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.6875rem;
line-height: 1.45;
margin: 0.2rem 0 0;
}
.hux-modes-active {
background: color-mix(in srgb, #22d3ee 12%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 34%, transparent);
border-radius: 999px;
color: color-mix(in srgb, currentColor 90%, #22d3ee);
font-size: 0.6875rem;
font-weight: 650;
padding: 0.25rem 0.55rem;
white-space: nowrap;
}
.hux-mode-grid {
border: 0;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin: 0.75rem 0 0;
padding: 0;
}
.hux-mode-card {
cursor: pointer;
min-width: 0;
position: relative;
}
.hux-mode-card input {
height: 1px;
opacity: 0;
position: absolute;
width: 1px;
}
.hux-mode-card-copy {
background: color-mix(in srgb, currentColor 3%, transparent);
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.7rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 4.35rem;
padding: 0.6rem;
transition: border-color 140ms ease, background-color 140ms ease;
}
.hux-mode-card-copy strong {
font-size: 0.75rem;
}
.hux-mode-card-copy > span {
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.625rem;
line-height: 1.35;
}
.hux-mode-card input:checked + .hux-mode-card-copy {
background: color-mix(in srgb, #22d3ee 8%, transparent);
border-color: color-mix(in srgb, #22d3ee 62%, currentColor);
}
.hux-mode-card input:focus-visible + .hux-mode-card-copy,
.hux-route-pin select:focus-visible,
.hux-modes-advanced summary:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
}
.hux-mode-grid:disabled .hux-mode-card {
cursor: not-allowed;
opacity: 0.55;
}
.hux-modes-advanced {
border-top: 1px solid color-mix(in srgb, currentColor 11%, transparent);
font-size: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.6rem;
}
.hux-modes-advanced summary {
cursor: pointer;
font-weight: 600;
}
.hux-modes-advanced-panel {
display: grid;
gap: 0.7rem;
margin-top: 0.65rem;
}
.hux-modes-advanced dl {
display: grid;
gap: 0.35rem;
margin: 0;
}
.hux-modes-advanced dl div {
display: grid;
gap: 0.5rem;
grid-template-columns: minmax(7rem, 0.35fr) 1fr;
}
.hux-modes-advanced dt {
color: color-mix(in srgb, currentColor 56%, transparent);
}
.hux-modes-advanced dd {
margin: 0;
overflow-wrap: anywhere;
}
.hux-route-pin {
display: grid;
gap: 0.3rem;
}
.hux-route-pin span {
font-size: 0.6875rem;
font-weight: 650;
}
.hux-route-pin select {
background: color-mix(in srgb, currentColor 4%, transparent);
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.55rem;
color: inherit;
font: inherit;
max-width: 100%;
padding: 0.5rem 0.6rem;
}
.hux-modes-error {
border: 1px solid color-mix(in srgb, #fb7185 50%, transparent);
border-radius: 0.65rem;
color: color-mix(in srgb, currentColor 88%, #fb7185);
font-size: 0.75rem;
margin: 0;
padding: 0.65rem 0.75rem;
}
@container (max-width: 42rem) {
.hux-mode-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.hux-mode-card-copy {
transition: none;
}
}

View File

@ -0,0 +1,96 @@
/** Safe frontend types for the opt-in HUX-06 friendly mode selector. */
export type FriendlyModeName =
| "fast"
| "thoughtful"
| "research"
| "create"
| "private";
export type ModeProvider = "codex" | "claude" | "local";
export type ModeEffort = "low" | "medium" | "high" | "xhigh";
export interface RawModeIntent {
schema?: unknown;
mode?: unknown;
label?: unknown;
intent?: unknown;
constraints?: unknown;
switchyard?: unknown;
}
export interface ModeConstraints {
providers: ModeProvider[];
local_only: boolean;
effort: { min: ModeEffort; max: ModeEffort };
tools: {
web: "required" | "allowed" | "denied";
shell: "allowed" | "denied";
artifacts: "encouraged" | "allowed" | "denied";
delegate: "allowed" | "denied";
};
memory: { read: boolean; write: boolean };
citations_required: boolean;
retention: "default" | "ephemeral";
}
export interface FriendlyModeIntent {
schema: "hux.mode.v1";
mode: FriendlyModeName;
label: string;
intent: string;
constraints: ModeConstraints;
switchyard: {
route_id: string;
override_route_id?: string;
};
}
export interface RawResolvedRoute {
requested?: unknown;
resolved_target?: unknown;
provider?: unknown;
effort?: unknown;
}
export interface ResolvedRouteDisclosure {
requested: string;
target?: string;
provider?: ModeProvider;
effort?: ModeEffort;
}
export interface RawAdvancedRouteChoice {
route_id?: unknown;
label?: unknown;
provider?: unknown;
effort?: unknown;
local_only?: unknown;
}
export interface AdvancedRouteChoice {
routeId: string;
label: string;
provider: ModeProvider;
effort: ModeEffort;
localOnly: boolean;
}
export interface HuxFoundationClient {
apiVersion: string;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
export interface FriendlyModesAdapter {
apiVersion: "hux.v1";
enabled(): boolean;
endpoint(): string;
adaptCatalog(raw: unknown): FriendlyModeIntent[] | null;
adaptResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null;
buildRequest(
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices?: readonly AdvancedRouteChoice[],
): FriendlyModeIntent | null;
}

View File

@ -0,0 +1,166 @@
/** Accessible multimodal UI. It emits intents but never fetches, uploads, or opens devices. */
import { Camera, FileText, Image as ImageIcon, Mic2, MonitorUp, Pencil, Upload } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import {
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "./model.ts";
import { validateUpload } from "./security.ts";
import type { FileCandidate, MediaView, MultimodalProps } from "./types.ts";
function MediaPreview({item}: {item: MediaView}) {
if (item.kind === "image" && item.previewUrl) return <figure className="hux-mm-image">
<img src={item.previewUrl} alt={item.altText} />
<figcaption>{item.fileName} · version {item.artifactVersion}</figcaption>
</figure>;
if (item.kind === "document" && item.previewText) return <div className="hux-mm-document">
<strong>{item.fileName}</strong>
<pre tabIndex={0}><code>{item.previewText}</code></pre>
<small>Safe text preview; active document content is not executed.</small>
</div>;
const Icon = item.kind === "image" ? ImageIcon : item.kind === "audio" ? Mic2 : FileText;
return <div className="hux-mm-safe-preview" role="img" aria-label={`${item.kind} preview unavailable`}>
<Icon aria-hidden />
<strong>{item.fileName}</strong>
<span>{item.mime} · {Math.ceil(item.bytes / 1024)} KB</span>
<small>Content is not executed. Open an authorized artifact preview to inspect it.</small>
</div>;
}
function UploadPanel({props, authorization, setNotice}: {
props: MultimodalProps;
authorization: MultimodalProps["page"]["upload_authorization"];
setNotice: (message: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const authorized = authorization === "authorized";
function handle(files: FileList | readonly File[]) {
const accepted: FileCandidate[] = [];
const rejected: string[] = [];
for (const file of Array.from(files).slice(0, 20)) {
const result = validateUpload(file, String(authorization));
if (result.ok) accepted.push(result.file);
else rejected.push(`${file.name || "File"}: ${result.reason}`);
}
if (accepted.length) props.onUpload?.(accepted);
setNotice(rejected.length ? rejected.join(" ") : `${accepted.length} file${accepted.length === 1 ? "" : "s"} ready to upload.`);
}
return <section className="hux-mm-upload" aria-labelledby="hux-mm-upload-title"
onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault(); handle(event.dataTransfer.files);
}}>
<Upload aria-hidden />
<h3 id="hux-mm-upload-title">Add files or images</h3>
<p>Drop authorized PNG, JPEG, WebP, PDF, text, Markdown, or audio files here.</p>
<input ref={input} className="sr-only" type="file" multiple
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/markdown,audio/webm,audio/wav,audio/mpeg,audio/ogg"
onChange={(event) => event.currentTarget.files && handle(event.currentTarget.files)} />
{authorized ? <button type="button" disabled={props.busy} onClick={() => input.current?.click()}>
Choose files
</button> : <button type="button" disabled={props.busy || authorization === "denied"}
onClick={() => props.onRequestAuthorization?.("upload")}>
{authorization === "denied" ? "Uploads denied" : "Ask before uploading"}
</button>}
</section>;
}
function MediaEditor({item, props}: {item: MediaView; props: MultimodalProps}) {
const [draft, setDraft] = useState("");
const [page, setPage] = useState("");
if (item.kind === "audio") return null;
const image = item.kind === "image";
return <form className="hux-mm-editor" onSubmit={(event) => {
event.preventDefault();
if (image) props.onImageEdit?.(buildImageEdit(item, props.scope, draft));
else props.onAnnotate?.(buildAnnotation(item, props.scope, draft, page ? Number(page) : null));
setDraft(""); setPage("");
}}>
<label htmlFor={`hux-mm-edit-${item.id}`}>{image ? "Describe an image edit" : "Add a document annotation"}</label>
<textarea id={`hux-mm-edit-${item.id}`} value={draft} maxLength={image ? 2000 : 1000}
onChange={(event) => setDraft(event.currentTarget.value)} required />
{!image && <label>Page (optional)<input type="number" min="1" max="100000" value={page}
onChange={(event) => setPage(event.currentTarget.value)} /></label>}
<button type="submit" disabled={props.busy || !draft.trim()}>
<Pencil aria-hidden /> {image ? "Create linked variant" : "Save annotation intent"}
</button>
{image && <small>The next version retains artifact and variant lineage.</small>}
</form>;
}
function TranscriptEditor({props, page}: {props: MultimodalProps; page: ReturnType<typeof normalizeMultimodalPage>}) {
const transcript = page?.transcript;
const [text, setText] = useState(transcript?.text || "");
if (!transcript) return <p>No voice transcript is attached to this turn.</p>;
return <form className="hux-mm-transcript" onSubmit={(event) => {
event.preventDefault();
props.onCorrectTranscript?.(buildTranscriptCorrection(transcript, text, props.identity, props.scope));
}}>
<label htmlFor="hux-mm-transcript-text">Voice transcript · {transcript.language}</label>
<textarea id="hux-mm-transcript-text" value={text} maxLength={20000}
readOnly={!transcript.finalized} onChange={(event) => setText(event.currentTarget.value)} />
<button type="submit" disabled={props.busy || !transcript.finalized || !text.trim() || text === transcript.text}>
Correct transcript
</button>
{!transcript.finalized && <small>Correction unlocks when transcription is finalized.</small>}
</form>;
}
function CaptureButton({kind, props}: {kind: "camera" | "screen"; props: MultimodalProps}) {
const decision = captureAuthorization(props.autonomyPolicy, null, props.identity);
const Icon = kind === "camera" ? Camera : MonitorUp;
return <button type="button" disabled={props.busy || decision === "deny"} onClick={() => {
props.onCapture?.(buildCaptureIntent(kind, props.autonomyPolicy, props.identity, props.scope));
}}>
<Icon aria-hidden /> {decision === "deny" ? `${kind} denied` : `Ask to use ${kind}`}
</button>;
}
/** Render nothing unless foundation, projects, artifacts, autonomy, and multimodal are active. */
export function MultimodalChat(props: MultimodalProps) {
const enabled = multimodalEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeMultimodalPage(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
if (!enabled || !page) return null;
const selected = page.media.find((item) => item.id === selectedId) || page.media[0] || null;
return <section className="hux-multimodal" aria-label="Multimodal chat" aria-busy={props.busy}>
<header><div><span className="hux-mm-kicker">HUX-07</span><h2>Multimodal workspace</h2>
<p>Files, voice, and generated variants remain bound to this conversation.</p></div>
<div className="hux-mm-capture" aria-label="Live media permissions">
<CaptureButton kind="camera" props={props} /><CaptureButton kind="screen" props={props} />
</div>
</header>
{props.error && <p className="hux-mm-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-mm-warning" role="status">
Some media was withheld because its owner, binding, type, or lineage was invalid.
</p>}
<UploadPanel props={props} authorization={page.uploadAuthorization} setNotice={setNotice} />
<div className="hux-mm-workspace">
<nav aria-label="Conversation media"><ul>{page.media.map((item) => <li key={item.id}>
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
onClick={() => setSelectedId(item.id)}>{item.kind === "image" ? <ImageIcon aria-hidden /> :
<FileText aria-hidden />}<span>{item.fileName}</span></button>
</li>)}</ul>{!page.media.length && <p>No media is attached.</p>}</nav>
<main>{selected ? <><MediaPreview item={selected} /><MediaEditor key={selected.id} item={selected} props={props} /></> :
<p>Select media to preview or annotate it.</p>}</main>
<aside aria-label="Generation history"><h3>Variant history</h3>
{page.threads.map((thread) => <section key={thread.id}><h4>{thread.prompt}</h4><ol>
{thread.variants.map((variant, index) => <li key={variant.id}>
Variant {index + 1} · artifact v{variant.artifactVersion}
{variant.parentVariantId && <small>Linked to an earlier variant</small>}
</li>)}</ol></section>)}
{!page.threads.length && <p>No generated variants yet.</p>}
</aside>
</div>
<TranscriptEditor key={`${page.transcript?.id || "none"}:${page.transcript?.revision || 0}`}
props={props} page={page} />
<p className="sr-only" role="status" aria-live="polite">{notice ||
`${page.media.length} media items and ${page.threads.length} generation threads available`}</p>
</section>;
}

View File

@ -0,0 +1,61 @@
/** Same-origin endpoint descriptors for the future HUX-07 backend. */
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import { multimodalEnabled } from "./model.ts";
import { normalizeIdentity, normalizeScope, safeEndpointPart, sameIdentity, sameScope } from "./security.ts";
import type {
CaptureKind, MultimodalEndpoint, MultimodalEndpointContract,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(method: MultimodalEndpoint["method"], path: string,
responseSchema: string, requiredBody?: readonly string[]): MultimodalEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
/** Describe endpoints only. The caller owns authenticated transport and approval execution. */
export function createMultimodalEndpointContract(client: FoundationClient, scope: ArtifactScope,
flags?: Iterable<string>): MultimodalEndpointContract | null {
if (!multimodalEnabled(flags)) return null;
if (client.apiVersion !== "hux.v1" || !sameIdentity(normalizeIdentity(client.identity), client.identity) ||
!sameScope(normalizeScope(scope), scope)) throw new TypeError("Scoped HUX v1 client required");
const base = client.endpoint(`/projects/${safeEndpointPart(scope.projectId, "prj")}` +
`/conversations/${safeEndpointPart(scope.conversationId, "conv")}/multimodal`);
const child = (root: string, id: unknown, prefix: string, suffix = "") =>
`${base}/${root}/${safeEndpointPart(id, prefix)}${suffix}`;
return Object.freeze({
page: endpoint("GET", base, "hux.multimodal_page.v1"),
beginUpload: endpoint("POST", `${base}/uploads`, "hux.media_upload.v1",
["file_name", "mime", "bytes", "authorization_id"]),
finalizeUpload(uploadId) {
return endpoint("POST", child("uploads", uploadId, "upl", "/finalize"), "hux.media.v1",
["content_ref", "expected_upload_id"]);
},
editImage(mediaId) {
return endpoint("POST", child("media", mediaId, "med", "/edits"), "hux.media.v1",
["artifact_id", "expected_current_version", "instruction", "lineage"]);
},
annotate(mediaId) {
return endpoint("POST", child("media", mediaId, "med", "/annotations"), "hux.media.v1",
["artifact_id", "expected_current_version", "page", "note"]);
},
correctTranscript(transcriptId) {
return endpoint("POST", child("transcripts", transcriptId, "trn", "/corrections"),
"hux.voice_transcript.v1", ["turn_id", "expected_revision", "corrected_text"]);
},
requestCapture(kind: CaptureKind) {
if (!['camera', 'screen'].includes(kind)) throw new TypeError("Capture kind is invalid");
return endpoint("POST", `${base}/captures/${kind}/approvals`, "hux.approval.v1",
["capability", "required_decision", "conversation_id"]);
},
createVariant(threadId) {
return endpoint("POST", child("threads", threadId, "thr", "/variants"),
"hux.generation_thread.v1", ["media_id", "artifact_id", "artifact_version", "parent_variant_id"]);
},
});
}

View File

@ -0,0 +1,8 @@
export { MultimodalChat } from "./MultimodalChat.tsx";
export { createMultimodalEndpointContract } from "./endpoints.ts";
export {
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "./model.ts";
export { validateUpload } from "./security.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,192 @@
/** Fail-closed multimodal normalization and intent builders for HUX-07. */
import { actionAuthorization } from "../autonomy/model.ts";
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { ApprovalView, PolicyView } from "../autonomy/types.ts";
import {
isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl, safePreviewText, safeText,
sameIdentity, sameScope,
} from "./security.ts";
import { validateUpload } from "./security.ts";
import type {
AnnotationIntent, CaptureIntent, CaptureKind, GenerationThread, GenerationVariant,
ImageEditIntent, MediaKind, MediaView, MultimodalPage, RawMediaEnvelope,
TranscriptCorrectionIntent, TranscriptView,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const ARTIFACTS_FLAG = "hux.artifacts";
export const AUTONOMY_FLAG = "hux.autonomy";
export const MULTIMODAL_FLAG = "hux.multimodal";
export const REQUIRED_FLAGS = Object.freeze([
FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG,
]);
export function multimodalEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return REQUIRED_FLAGS.every((flag) => active.has(flag));
}
function normalizeAnnotation(raw: unknown): MediaView["annotations"][number] | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const page = value.page === null ? null : value.page;
const note = safeText(value.note, "", 1_000);
return isOpaqueId(value.id, "ann") && (page === null ||
(Number.isSafeInteger(page) && (page as number) > 0 && (page as number) <= 100_000)) && note ?
{id: value.id, page: page as number | null, note} : null;
}
function normalizeMedia(raw: unknown, owner: string, scope: ArtifactScope): MediaView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const kind = value.kind as MediaKind;
const fileName = safeText(value.file_name, "", 180);
const altText = safeText(value.alt_text, "Preview", 500);
const upload = validateUpload({name: fileName, type: String(value.mime), size: Number(value.bytes)}, "authorized");
if (value.schema !== "hux.media.v1" || !isOpaqueId(value.id, "med") || value.owner !== owner ||
value.project_id !== scope.projectId || value.conversation_id !== scope.conversationId ||
!isOpaqueId(value.artifact_id, "art") || !Number.isSafeInteger(value.artifact_version) ||
(value.artifact_version as number) < 1 || !["image", "document", "audio"].includes(kind) ||
!fileName || !Number.isSafeInteger(value.bytes) || !upload.ok || upload.kind !== kind ||
typeof value.mime !== "string" || !Array.isArray(value.annotations) || value.annotations.length > 500) return null;
const annotations = value.annotations.map(normalizeAnnotation);
if (annotations.some((item) => item === null)) return null;
const previewUrl = value.preview_url === null || value.preview_url === undefined ? null : safeBlobUrl(value.preview_url);
const previewText = value.preview_text === null || value.preview_text === undefined ? null : safePreviewText(value.preview_text);
if ((value.preview_url && (!previewUrl || value.preview_authorized !== true)) ||
(value.preview_text !== null && value.preview_text !== undefined && (!previewText || kind !== "document")) ||
(previewUrl && kind !== "image") || (previewText && value.preview_authorized !== true)) return null;
let lineage: MediaView["lineage"] = null;
if (value.lineage !== null && value.lineage !== undefined) {
const parent = value.lineage as Record<string, unknown> | null;
if (!parent || !isOpaqueId(parent.media_id, "med") || !isOpaqueId(parent.artifact_id, "art") ||
!Number.isSafeInteger(parent.artifact_version) || (parent.artifact_version as number) < 1 ||
parent.media_id === value.id) return null;
lineage = {mediaId: parent.media_id, artifactId: parent.artifact_id,
artifactVersion: parent.artifact_version as number};
}
return {id: value.id, owner, artifactId: value.artifact_id,
artifactVersion: value.artifact_version as number, kind, fileName, mime: value.mime,
bytes: value.bytes as number, previewUrl, previewText, altText,
annotations: annotations as MediaView["annotations"], lineage};
}
function normalizeVariant(raw: unknown, allowed: ReadonlyMap<string, MediaView>): GenerationVariant | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const media = allowed.get(String(value.media_id));
const parent = value.parent_variant_id;
if (!isOpaqueId(value.id, "var") || !media || value.artifact_id !== media.artifactId ||
value.artifact_version !== media.artifactVersion ||
!(parent === null || isOpaqueId(parent, "var")) || !isUtc(value.created_at)) return null;
return {id: value.id, mediaId: media.id, artifactId: media.artifactId,
artifactVersion: media.artifactVersion, parentVariantId: parent as string | null,
createdAt: value.created_at};
}
function normalizeThread(raw: unknown, media: ReadonlyMap<string, MediaView>, owner: string,
scope: ArtifactScope): GenerationThread | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const prompt = safeText(value.prompt, "", 2_000);
if (!isOpaqueId(value.id, "thr") || value.owner !== owner || value.project_id !== scope.projectId ||
value.conversation_id !== scope.conversationId || !prompt || !Array.isArray(value.variants) ||
value.variants.length < 1 || value.variants.length > 200) return null;
const variants = value.variants.map((item) => normalizeVariant(item, media));
if (variants.some((item) => item === null)) return null;
const ordered = variants as GenerationVariant[];
const ids = new Set<string>();
for (const variant of ordered) {
if (ids.has(variant.id) || (variant.parentVariantId && !ids.has(variant.parentVariantId))) return null;
ids.add(variant.id);
}
return {id: value.id, prompt, variants: ordered};
}
function normalizeTranscript(raw: unknown, owner: string, scope: ArtifactScope): TranscriptView | null {
if (raw === null || raw === undefined) return null;
if (typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const text = safeText(value.text, "", 20_000);
const language = safeText(value.language, "", 40);
return value.schema === "hux.voice_transcript.v1" && value.owner === owner &&
value.project_id === scope.projectId && value.conversation_id === scope.conversationId &&
isOpaqueId(value.id, "trn") &&
isOpaqueId(value.turn_id, "turn") && Number.isSafeInteger(value.revision) &&
(value.revision as number) >= 1 && Boolean(language) && Boolean(text) &&
typeof value.finalized === "boolean" ? {id: value.id, turnId: value.turn_id,
revision: value.revision as number, language, text, finalized: value.finalized} : null;
}
export function normalizeMultimodalPage(raw: RawMediaEnvelope, expected: HuxIdentity,
scope: ArtifactScope): MultimodalPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.multimodal_page.v1" ||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), expected) ||
!sameScope(normalizeScope(raw.binding), scope) || !Array.isArray(raw.media) ||
raw.media.length > 200 || !Array.isArray(raw.threads) || raw.threads.length > 100) return null;
if (!["authorized", "requires_approval", "denied"].includes(String(
(raw as Record<string, unknown>).upload_authorization))) return null;
const normalized = raw.media.map((item) => normalizeMedia(item, expected.userRef, scope));
const ids = new Set<string>();
const unique = normalized.filter((item): item is MediaView => Boolean(item && !ids.has(item.id) && ids.add(item.id)));
const allMedia = new Map(unique.map((item) => [item.id, item]));
const media = unique.filter((item) => !item.lineage || (() => {
const parent = allMedia.get(item.lineage!.mediaId);
return Boolean(parent && parent.artifactId === item.lineage!.artifactId &&
parent.artifactVersion === item.lineage!.artifactVersion);
})());
const mediaMap = new Map(media.map((item) => [item.id, item]));
const threads = raw.threads.map((item) => normalizeThread(item, mediaMap, expected.userRef, scope));
const transcript = normalizeTranscript(raw.transcript, expected.userRef, scope);
return {media, threads: threads.filter((item): item is GenerationThread => item !== null), transcript,
uploadAuthorization: (raw as Record<string, unknown>).upload_authorization as MultimodalPage["uploadAuthorization"],
rejected: normalized.length - media.length + threads.filter((item) => item === null).length +
(raw.transcript && !transcript ? 1 : 0)};
}
function baseIntent(media: MediaView, scope: ArtifactScope) {
return {artifactId: media.artifactId, expectedVersion: media.artifactVersion, scope: {...scope}};
}
export function buildImageEdit(media: MediaView, scope: ArtifactScope,
instruction: string): ImageEditIntent {
const clean = safeText(instruction, "", 2_000);
if (media.kind !== "image" || !clean) throw new TypeError("A bounded image edit instruction is required");
return {...baseIntent(media, scope), mediaId: media.id, instruction: clean,
lineage: {artifactId: media.artifactId, version: media.artifactVersion}};
}
export function buildAnnotation(media: MediaView, scope: ArtifactScope,
note: string, page: number | null): AnnotationIntent {
const clean = safeText(note, "", 1_000);
if (media.kind !== "document" || !clean || !(page === null ||
(Number.isSafeInteger(page) && page > 0 && page <= 100_000))) {
throw new TypeError("A bounded document annotation is required");
}
return {...baseIntent(media, scope), mediaId: media.id, page, note: clean};
}
export function buildTranscriptCorrection(transcript: TranscriptView, correctedText: string,
identity: HuxIdentity, scope: ArtifactScope): TranscriptCorrectionIntent {
const clean = safeText(correctedText, "", 20_000);
if (!transcript.finalized || !clean) throw new TypeError("Only a finalized transcript can be corrected");
return {transcriptId: transcript.id, turnId: transcript.turnId,
expectedRevision: transcript.revision, correctedText: clean, identity: {...identity}, scope: {...scope}};
}
/** Camera and screen input always require a fresh Ask First decision. */
export function captureAuthorization(policy: PolicyView | null, approval: ApprovalView | null,
identity: HuxIdentity): "approval_required" | "deny" {
return actionAuthorization(policy, "network", approval, identity) === "deny" ? "deny" : "approval_required";
}
export function buildCaptureIntent(kind: CaptureKind, policy: PolicyView | null,
identity: HuxIdentity, scope: ArtifactScope): CaptureIntent {
if (!['camera', 'screen'].includes(kind) || captureAuthorization(policy, null, identity) === "deny") {
throw new TypeError("Capture is denied by the autonomy policy");
}
return {kind, autonomyCapability: "network", requiredDecision: "ask",
identity: {...identity}, scope: {...scope}};
}

View File

@ -0,0 +1,99 @@
/** Identity, binding, media, and display guards for HUX-07. */
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { FileCandidate, MediaKind, UploadValidation } from "./types.ts";
const ID = /^[a-z]{2,8}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/g;
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const MIME_KIND = new Map<string, MediaKind>([
["image/png", "image"], ["image/jpeg", "image"], ["image/webp", "image"],
["application/pdf", "document"], ["text/plain", "document"],
["text/markdown", "document"], ["audio/webm", "audio"], ["audio/wav", "audio"],
["audio/mpeg", "audio"], ["audio/ogg", "audio"],
]);
const MAX_BYTES: Readonly<Record<MediaKind, number>> = {
image: 20 * 1024 * 1024, document: 25 * 1024 * 1024, audio: 50 * 1024 * 1024,
};
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit = 500): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(SECRET, "$1=[redacted]").replace(CONTROL, " ")
.replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const tenantRef = value.tenant_ref ?? value.tenantRef;
const userRef = value.user_ref ?? value.userRef;
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
typeof userRef !== "string" || !USER.test(userRef) ||
!["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface))) return null;
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
}
export function normalizeScope(raw: unknown): ArtifactScope | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const projectId = value.project_id ?? value.projectId;
const conversationId = value.conversation_id ?? value.conversationId;
return isOpaqueId(projectId, "prj") && isOpaqueId(conversationId, "conv") ?
{projectId, conversationId} : null;
}
export function sameIdentity(left: HuxIdentity | null, right: HuxIdentity): boolean {
return Boolean(left && left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface);
}
export function sameScope(left: ArtifactScope | null, right: ArtifactScope): boolean {
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
}
/** Preview URLs must be local object URLs. Inline data and remote resources are never accepted. */
export function safeBlobUrl(value: unknown): string | null {
return typeof value === "string" && /^blob:(?:https?:\/\/|null\/)[^\s]+$/.test(value) ? value : null;
}
/** Text previews render only as text nodes; controls that can spoof layout are rejected. */
export function safePreviewText(value: unknown, limit = 200_000): string | null {
return typeof value === "string" && value.length <= limit &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ? value : null;
}
export function classifyMime(value: unknown): MediaKind | null {
return typeof value === "string" ? MIME_KIND.get(value.toLowerCase()) || null : null;
}
/** Validate before reading bytes or handing a File to a transport adapter. */
export function validateUpload(file: FileCandidate, authorization: string): UploadValidation {
if (authorization !== "authorized") return {ok: false, reason: authorization === "requires_approval" ?
"Upload approval is required before selecting media." : "Media uploads are denied for this conversation."};
const kind = classifyMime(file?.type);
if (!kind) return {ok: false, reason: "This file type is not supported."};
if (!Number.isSafeInteger(file.size) || file.size < 1 || file.size > MAX_BYTES[kind]) {
return {ok: false, reason: `This ${kind} is empty or exceeds the ${MAX_BYTES[kind] / 1024 / 1024} MB limit.`};
}
const name = safeText(file.name, "", 180);
if (!name || /[/\\]/.test(name)) return {ok: false, reason: "The file name is invalid."};
return {ok: true, kind, file: {name, type: file.type.toLowerCase(), size: file.size}};
}
export function safeEndpointPart(value: unknown, prefix: string): string {
if (!isOpaqueId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
return encodeURIComponent(value);
}

View File

@ -0,0 +1,147 @@
.hux-multimodal {
--mm-border: color-mix(in srgb, currentColor 16%, transparent);
display: grid;
gap: 1rem;
min-width: 0;
}
.hux-multimodal > header,
.hux-mm-capture,
.hux-mm-workspace,
.hux-mm-editor {
display: flex;
gap: .75rem;
}
.hux-multimodal > header {
align-items: start;
justify-content: space-between;
}
.hux-multimodal h2,
.hux-multimodal h3,
.hux-multimodal p {
margin-block: .2rem;
}
.hux-mm-kicker {
font-size: .72rem;
letter-spacing: .14em;
text-transform: uppercase;
}
.hux-mm-upload {
border: 1px dashed var(--mm-border);
border-radius: .8rem;
padding: 1.25rem;
text-align: center;
}
.hux-mm-workspace {
align-items: stretch;
min-height: 24rem;
}
.hux-mm-workspace > nav,
.hux-mm-workspace > aside {
flex: 0 0 min(18rem, 25%);
}
.hux-mm-workspace > main {
border-inline: 1px solid var(--mm-border);
flex: 1;
min-width: 0;
padding-inline: 1rem;
}
.hux-mm-workspace ul,
.hux-mm-workspace ol {
list-style: none;
margin: 0;
padding: 0;
}
.hux-mm-workspace nav button {
align-items: center;
display: flex;
gap: .5rem;
overflow-wrap: anywhere;
width: 100%;
}
.hux-mm-workspace button[aria-current="page"] {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.hux-mm-image img {
display: block;
height: auto;
max-height: 28rem;
max-width: 100%;
object-fit: contain;
}
.hux-mm-safe-preview {
align-items: center;
border: 1px solid var(--mm-border);
border-radius: .6rem;
display: grid;
gap: .35rem;
justify-items: center;
min-height: 12rem;
padding: 1rem;
text-align: center;
}
.hux-mm-editor,
.hux-mm-transcript {
flex-direction: column;
margin-top: 1rem;
}
.hux-mm-editor textarea,
.hux-mm-transcript textarea {
min-height: 6rem;
resize: vertical;
}
.hux-mm-error,
.hux-mm-warning {
border-inline-start: .25rem solid currentColor;
padding: .65rem .85rem;
}
.hux-mm-workspace aside li {
border-bottom: 1px solid var(--mm-border);
display: grid;
padding-block: .5rem;
}
@media (max-width: 62rem) {
.hux-mm-workspace,
.hux-multimodal > header {
flex-direction: column;
}
.hux-mm-workspace > nav,
.hux-mm-workspace > aside {
flex-basis: auto;
}
.hux-mm-workspace > main {
border-block: 1px solid var(--mm-border);
border-inline: 0;
padding-block: 1rem;
padding-inline: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-multimodal *,
.hux-multimodal *::before,
.hux-multimodal *::after {
scroll-behavior: auto !important;
transition-duration: .01ms !important;
}
}

View File

@ -0,0 +1,140 @@
/** Frontend-only contracts for default-off HUX-07 multimodal chat. */
import type { ArtifactMutationIntent, ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { PolicyView } from "../autonomy/types.ts";
export type MediaKind = "image" | "document" | "audio";
export type MediaAuthorization = "authorized" | "requires_approval" | "denied";
export type CaptureKind = "camera" | "screen";
export interface RawMediaEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
binding?: unknown;
media?: unknown;
threads?: unknown;
transcript?: unknown;
upload_authorization?: unknown;
}
export interface MediaView {
id: string;
owner: string;
artifactId: string;
artifactVersion: number;
kind: MediaKind;
fileName: string;
mime: string;
bytes: number;
previewUrl: string | null;
previewText: string | null;
altText: string;
annotations: ReadonlyArray<{id: string; page: number | null; note: string}>;
lineage: {mediaId: string; artifactId: string; artifactVersion: number} | null;
}
export interface GenerationVariant {
id: string;
mediaId: string;
artifactId: string;
artifactVersion: number;
parentVariantId: string | null;
createdAt: string;
}
export interface GenerationThread {
id: string;
prompt: string;
variants: GenerationVariant[];
}
export interface TranscriptView {
id: string;
turnId: string;
revision: number;
language: string;
text: string;
finalized: boolean;
}
export interface MultimodalPage {
media: MediaView[];
threads: GenerationThread[];
transcript: TranscriptView | null;
uploadAuthorization: MediaAuthorization;
rejected: number;
}
export interface FileCandidate {
name: string;
type: string;
size: number;
}
export type UploadValidation =
| {ok: true; kind: MediaKind; file: FileCandidate}
| {ok: false; reason: string};
export interface ImageEditIntent extends ArtifactMutationIntent {
mediaId: string;
instruction: string;
lineage: {artifactId: string; version: number};
}
export interface AnnotationIntent extends ArtifactMutationIntent {
mediaId: string;
page: number | null;
note: string;
}
export interface TranscriptCorrectionIntent {
transcriptId: string;
turnId: string;
expectedRevision: number;
correctedText: string;
identity: HuxIdentity;
scope: ArtifactScope;
}
export interface CaptureIntent {
kind: CaptureKind;
autonomyCapability: "network";
requiredDecision: "ask";
identity: HuxIdentity;
scope: ArtifactScope;
}
export interface MultimodalProps {
flags?: Iterable<string>;
identity: HuxIdentity;
scope: ArtifactScope;
page: RawMediaEnvelope;
autonomyPolicy: PolicyView | null;
busy?: boolean;
error?: string | null;
onUpload?: (files: readonly FileCandidate[]) => void;
onRequestAuthorization?: (action: "upload") => void;
onImageEdit?: (intent: ImageEditIntent) => void;
onAnnotate?: (intent: AnnotationIntent) => void;
onCorrectTranscript?: (intent: TranscriptCorrectionIntent) => void;
onCapture?: (intent: CaptureIntent) => void;
}
export interface MultimodalEndpoint {
method: "GET" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface MultimodalEndpointContract {
page: MultimodalEndpoint;
beginUpload: MultimodalEndpoint;
finalizeUpload(uploadId: string): MultimodalEndpoint;
editImage(mediaId: string): MultimodalEndpoint;
annotate(mediaId: string): MultimodalEndpoint;
correctTranscript(transcriptId: string): MultimodalEndpoint;
requestCapture(kind: CaptureKind): MultimodalEndpoint;
createVariant(threadId: string): MultimodalEndpoint;
}

View File

@ -0,0 +1,117 @@
/** Restrained, accessible HUX-09 suggestion card. No action runs implicitly. */
import {useEffect, useState} from "react";
import {buildActionIntent, onboardingEnabled} from "./model.ts";
import type {
OnboardingActionIntent,
OnboardingClient,
SuggestionDecision,
SuggestionEvaluation,
} from "./types.ts";
interface OnboardingSuggestionsProps {
flags?: Iterable<string>;
client: OnboardingClient;
evaluation: SuggestionEvaluation | null;
disabled?: boolean;
onAction(intent: OnboardingActionIntent): void | Promise<void>;
onResolved?(decision: SuggestionDecision): void;
}
const CONTEXT_LABELS = {
first_session: "Shown because this is a new workspace session.",
empty_project: "Shown because this project has no conversations yet.",
after_artifact: "Shown because you just created an artifact.",
after_research: "Shown because a research task just finished.",
after_approval: "Shown because an approved task just finished.",
idle: "Shown after this conversation became idle.",
} as const;
function actionLabel(evaluation: SuggestionEvaluation): string | null {
const {action} = evaluation.suggestion;
if (action.type === "open_mode") return "Choose a mode";
if (action.type === "create_project") return "Add to a project";
if (action.type === "open_memory") return "Review memory";
if (action.type === "open_artifacts") return "View artifacts";
if (action.type === "start_workflow") {
return action.payload?.intent === "schedule_weekly" ? "Run weekly" : "Save workflow";
}
return null;
}
/** Render nothing until the four HUX dependencies and an eligible claim exist. */
export function OnboardingSuggestions(props: OnboardingSuggestionsProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [resolved, setResolved] = useState(false);
const enabled = onboardingEnabled(props.flags) && props.client?.enabled();
const evaluation = props.evaluation;
useEffect(() => {
setBusy(false);
setError("");
setResolved(false);
}, [evaluation?.claimId]);
if (!enabled || !evaluation || evaluation.privacy.noStore || resolved) return null;
if (evaluation.suggestion.action.type === "open_memory" &&
!evaluation.privacy.memorySuggestionsAllowed) return null;
const label = actionLabel(evaluation);
async function resolve(decision: SuggestionDecision): Promise<void> {
if (busy || props.disabled) return;
setBusy(true);
setError("");
try {
if (decision === "acted") {
const intent = buildActionIntent(evaluation);
if (!intent) throw new Error("This suggestion has no safe action");
await props.onAction(intent);
}
await props.client.decide(evaluation, decision);
setResolved(true);
props.onResolved?.(decision);
} catch (_) {
setError("That choice could not be saved. Nothing else was changed.");
} finally {
setBusy(false);
}
}
return (
<aside className="hux-onboarding" aria-labelledby={`hux-onboarding-${evaluation.suggestion.id}`}>
<div className="hux-onboarding-copy">
<span className="hux-onboarding-kicker">Optional next step</span>
<h2 id={`hux-onboarding-${evaluation.suggestion.id}`}>
{evaluation.suggestion.title}
</h2>
<p>{evaluation.suggestion.body}</p>
<details className="hux-onboarding-why">
<summary>Why am I seeing this?</summary>
<p>{CONTEXT_LABELS[evaluation.suggestion.trigger.context]}</p>
</details>
</div>
<div className="hux-onboarding-controls" aria-busy={busy}>
{label && (
<button type="button" className="hux-onboarding-primary"
disabled={busy || props.disabled} onClick={() => void resolve("acted")}>
{label}
</button>
)}
<button type="button" disabled={busy || props.disabled}
onClick={() => void resolve("dismiss")}>
Not now
</button>
<button type="button" className="hux-onboarding-never"
disabled={busy || props.disabled} onClick={() => void resolve("never_again")}>
Don&apos;t suggest this again
</button>
</div>
<div className="hux-onboarding-status" role="status" aria-live="polite">
{busy ? "Saving your choice…" : ""}
</div>
{error && <p className="hux-onboarding-error" role="alert">{error}</p>}
</aside>
);
}

View File

@ -0,0 +1,145 @@
/** Same-origin, session-bound transport for contextual HUX-09 suggestions. */
import {ACCEPT, API_VERSION, DECISION_SCHEMA, EVALUATION_SCHEMA} from "./contracts.ts";
import {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
PRIVACY_FLAG,
PROJECTS_FLAG,
normalizeDecisionState,
normalizeEvaluation,
} from "./model.ts";
import {
asRecord,
exactKeys,
isId,
normalizeIdentity,
normalizeScope,
sameIdentity,
scopedSuggestionPath,
} from "./security.ts";
import type {
FetchResponse,
HuxFoundationClient,
OnboardingScope,
RawSuggestionState,
SuggestionDecision,
SuggestionEvaluation,
SuggestionState,
} from "./types.ts";
interface ClientOptions {
client: HuxFoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
export class OnboardingContractError extends Error {
constructor(message: string) {
super(message);
this.name = "OnboardingContractError";
}
}
function hasCoreFlags(client: HuxFoundationClient): boolean {
return client.apiVersion === API_VERSION && [
FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG,
].every((flag) => client.enabled(flag));
}
export function createOnboardingClient(options: ClientOptions) {
const client = options?.client;
const fetcher = options?.fetcher ||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Onboarding requires HUX and fetch clients");
const identity = normalizeIdentity({
tenant_ref: client.identity?.tenantRef,
user_ref: client.identity?.userRef,
surface: client.identity?.surface,
});
if (!identity) throw new TypeError("Onboarding requires a scoped HUX identity");
const seenClaims = new Set<string>();
const activeClaims = new Map<string, SuggestionEvaluation>();
async function request(path: string, body: Record<string, unknown>): Promise<unknown> {
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
const response = await fetcher(client.endpoint(path), {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, "Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) throw new OnboardingContractError(
response.status === 409 ? "This suggestion is no longer current" :
`Onboarding request failed (${response.status})`,
);
return response.json();
}
async function evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null> {
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
const safeScope = normalizeScope(scope, identity);
if (!safeScope) throw new TypeError("Onboarding trigger is outside this session");
const raw = await request("/onboarding/evaluate", {
schema: "hux.suggestion_evaluation_request.v1",
session_id: safeScope.sessionId,
conversation_id: safeScope.conversationId,
trigger: safeScope.trigger,
});
if (raw === null) return null;
const result = normalizeEvaluation(raw, identity, safeScope, client.enabled(PRIVACY_FLAG));
if (!result || seenClaims.has(result.claimId)) return null;
seenClaims.add(result.claimId);
activeClaims.set(result.claimId, result);
return result;
}
function normalizeDecision(
raw: unknown,
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): SuggestionState | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id",
"claim_id", "suggestion_id", "state",
]) || value.schema !== DECISION_SCHEMA || value.api_version !== API_VERSION ||
value.session_id !== evaluation.scope.sessionId ||
value.conversation_id !== evaluation.scope.conversationId ||
value.claim_id !== evaluation.claimId ||
value.suggestion_id !== evaluation.suggestion.id ||
!isId(value.claim_id, "clm") || !isId(value.suggestion_id, "sug")) return null;
const actualIdentity = normalizeIdentity(value.identity);
if (!actualIdentity || !sameIdentity(actualIdentity, identity)) return null;
return normalizeDecisionState(value.state as RawSuggestionState, evaluation, decision);
}
async function decide(
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): Promise<SuggestionState> {
if (!["dismiss", "never_again", "acted"].includes(decision) ||
activeClaims.get(evaluation?.claimId) !== evaluation) {
throw new TypeError("Suggestion decision is not bound to this session");
}
const path = scopedSuggestionPath("/onboarding/suggestions", evaluation.suggestion.id);
const raw = await request(path, {
schema: "hux.suggestion_decision_request.v1",
session_id: evaluation.scope.sessionId,
conversation_id: evaluation.scope.conversationId,
claim_id: evaluation.claimId,
suggestion_id: evaluation.suggestion.id,
expected_shows: evaluation.state.shows,
decision,
});
const state = normalizeDecision(raw, evaluation, decision);
if (!state) throw new OnboardingContractError("Suggestion decision crossed its scope");
activeClaims.delete(evaluation.claimId);
return state;
}
return Object.freeze({
enabled: () => hasCoreFlags(client), evaluate, decide,
schemas: Object.freeze({evaluation: EVALUATION_SCHEMA, decision: DECISION_SCHEMA}),
});
}

View File

@ -0,0 +1,32 @@
/** Same-origin backend contracts required by the HUX-09 frontend. */
export const API_VERSION = "hux.v1";
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
export const EVALUATION_SCHEMA = "hux.suggestion_evaluation.v1";
export const DECISION_SCHEMA = "hux.suggestion_decision.v1";
/**
* POST /hux/v1/onboarding/evaluate
* POST /hux/v1/onboarding/suggestions/{sug_id}/decisions
*
* Evaluate accepts only schema, session_id, conversation_id and the exact
* trigger. Identity MUST NOT be accepted from browser storage, query
* parameters, or request bodies; derive it from the authenticated session.
* The server verifies that both opaque ids belong to that identity and surface.
*
* Before returning a candidate, evaluation MUST atomically apply never_again,
* max_shows and cooldown_seconds from suggestion.schema.json, increment shows,
* and bind a single-use claim_id to identity + session + conversation + trigger.
* If no_store is active, the server MUST NOT write suppression state and
* returns suggestion/state/claim_id as null. Memory suggestions require the
* hux.privacy capability and memory_suggestions_allowed=true.
*
* Decisions accept only schema, session_id, conversation_id, claim_id,
* suggestion_id, expected_shows and decision. The server rechecks the claim
* binding and returns 409 for stale/replayed claims. A decision never performs
* the suggested action; it only persists dismiss, never-again, or acted state.
*/
export const BACKEND_ENDPOINTS = Object.freeze({
evaluate: "/onboarding/evaluate",
decision: "/onboarding/suggestions/{sug_id}/decisions",
});

View File

@ -0,0 +1,20 @@
/** Public integration surface for the inert HUX-09 onboarding module. */
export {createOnboardingClient, OnboardingContractError} from "./client.ts";
export {BACKEND_ENDPOINTS} from "./contracts.ts";
export {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
PRIVACY_FLAG,
PROJECTS_FLAG,
REQUIRED_FLAGS,
buildActionIntent,
normalizeDecisionState,
normalizeEvaluation,
normalizeState,
normalizeSuggestion,
onboardingEnabled,
} from "./model.ts";
export {OnboardingSuggestions} from "./OnboardingSuggestions.tsx";
export type * from "./types.ts";

View File

@ -0,0 +1,246 @@
/** Fail-closed suggestion.schema.json adapters for HUX-09. */
import {
asRecord,
exactKeys,
isId,
isSafeText,
isUtc,
normalizeIdentity,
normalizeTrigger,
sameIdentity,
sameTrigger,
} from "./security.ts";
import type {
OnboardingActionIntent,
OnboardingScope,
OnboardingSuggestion,
RawSuggestion,
RawSuggestionState,
ScopedIdentity,
SuggestionActionType,
SuggestionDecision,
SuggestionEvaluation,
SuggestionKind,
SuggestionState,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
export const ONBOARDING_FLAG = "hux.onboarding";
export const PRIVACY_FLAG = "hux.privacy";
export const REQUIRED_FLAGS = Object.freeze([
FOUNDATION_FLAG,
PROJECTS_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
]);
const KINDS = new Set<SuggestionKind>(["workflow", "project", "feature", "tip"]);
const ACTIONS = new Set<SuggestionActionType>([
"open_mode", "create_project", "open_memory", "open_artifacts",
"start_workflow", "none",
]);
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
const INTENTS = new Set(["schedule_weekly", "save_reusable"]);
const KIND_ACTIONS: Readonly<Record<SuggestionKind, ReadonlySet<SuggestionActionType>>> = {
workflow: new Set(["start_workflow"]),
project: new Set(["create_project"]),
feature: new Set(["open_mode", "open_memory", "open_artifacts"]),
tip: new Set(["none"]),
};
function finiteInteger(value: unknown, min: number, max: number): value is number {
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
}
function normalizePayload(
type: SuggestionActionType,
raw: unknown,
): Readonly<Record<string, string>> | null | undefined {
const value = raw === undefined ? {} : asRecord(raw);
if (!value) return null;
if (type === "start_workflow") {
return exactKeys(value, ["intent"]) && INTENTS.has(value.intent as string) ?
{intent: value.intent as string} : null;
}
if (type === "create_project") {
if (!exactKeys(value, [], ["prefill_title"])) return null;
return value.prefill_title === undefined ? {} :
isSafeText(value.prefill_title, 1, 120) ? {prefill_title: value.prefill_title} : null;
}
if (type === "open_mode") {
if (!exactKeys(value, [], ["mode"])) return null;
return value.mode === undefined ? {} : MODES.has(value.mode as string) ?
{mode: value.mode as string} : null;
}
if (type === "open_memory") {
return exactKeys(value, [], ["view"]) &&
(value.view === undefined || value.view === "suggestions") ?
(value.view ? {view: "suggestions"} : {}) : null;
}
if (type === "open_artifacts") {
return exactKeys(value, [], ["view"]) &&
(value.view === undefined || value.view === "current") ?
(value.view ? {view: "current"} : {}) : null;
}
return exactKeys(value, []) ? undefined : null;
}
export function onboardingEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return REQUIRED_FLAGS.every((flag) => enabled.has(flag));
}
export function normalizeSuggestion(
raw: RawSuggestion,
expectedTrigger: OnboardingScope["trigger"],
): OnboardingSuggestion | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "id", "kind", "trigger", "title", "body", "priority", "suppression",
], ["action"]) || value.schema !== "hux.suggestion.v1" || !isId(value.id, "sug") ||
!KINDS.has(value.kind as SuggestionKind) || !isSafeText(value.title, 1, 80) ||
!isSafeText(value.body, 1, 280) || !finiteInteger(value.priority, 0, 100)) return null;
const trigger = normalizeTrigger(value.trigger);
const suppression = asRecord(value.suppression);
if (!trigger || !sameTrigger(trigger, expectedTrigger) || !suppression ||
!exactKeys(suppression, [
"dismissable", "max_shows", "cooldown_seconds", "never_again_supported",
]) || suppression.dismissable !== true || suppression.never_again_supported !== true ||
!finiteInteger(suppression.max_shows, 1, 5) ||
!finiteInteger(suppression.cooldown_seconds, 3600, Number.MAX_SAFE_INTEGER)) return null;
const action = value.action === undefined ? {type: "none"} : asRecord(value.action);
if (!action || !exactKeys(action, ["type"], ["payload"]) ||
!ACTIONS.has(action.type as SuggestionActionType)) return null;
const type = action.type as SuggestionActionType;
const payload = normalizePayload(type, action.payload);
const kind = value.kind as SuggestionKind;
if (payload === null || !KIND_ACTIONS[kind].has(type)) return null;
return {
schema: "hux.suggestion.v1", id: value.id as string, kind, trigger,
title: value.title as string, body: value.body as string,
action: {type, ...(payload === undefined ? {} : {payload})},
priority: value.priority as number,
suppression: {
dismissable: true, maxShows: suppression.max_shows as number,
cooldownSeconds: suppression.cooldown_seconds as number,
neverAgainSupported: true,
},
};
}
export function normalizeState(
raw: RawSuggestionState,
suggestion: OnboardingSuggestion,
owner: string,
): SuggestionState | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
value.owner !== owner || value.suggestion_id !== suggestion.id ||
!finiteInteger(value.shows, 1, suggestion.suppression.maxShows) ||
!isUtc(value.last_shown_at) || value.never_again !== false ||
(value.dismissed_at !== undefined && !isUtc(value.dismissed_at)) ||
(value.acted_at !== undefined && !isUtc(value.acted_at))) return null;
return {
schema: "hux.suggestion_state.v1", owner, suggestionId: suggestion.id,
shows: value.shows as number, lastShownAt: value.last_shown_at as string,
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
neverAgain: false,
};
}
export function normalizeDecisionState(
raw: RawSuggestionState,
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): SuggestionState | null {
const value = asRecord(raw);
const suggestion = evaluation.suggestion;
if (!value || !exactKeys(value, [
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
value.owner !== evaluation.identity.userRef || value.suggestion_id !== suggestion.id ||
value.shows !== evaluation.state.shows || !isUtc(value.last_shown_at) ||
value.last_shown_at !== evaluation.state.lastShownAt ||
typeof value.never_again !== "boolean") return null;
const dismissed = value.dismissed_at === undefined || isUtc(value.dismissed_at);
const acted = value.acted_at === undefined || isUtc(value.acted_at);
const outcomeMatches = decision === "acted" ?
Boolean(value.acted_at) && value.never_again === false :
Boolean(value.dismissed_at) &&
value.never_again === (decision === "never_again");
if (!dismissed || !acted || !outcomeMatches) return null;
return {
schema: "hux.suggestion_state.v1", owner: evaluation.identity.userRef,
suggestionId: suggestion.id, shows: value.shows as number,
lastShownAt: value.last_shown_at as string,
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
neverAgain: value.never_again,
};
}
export function normalizeEvaluation(
raw: unknown,
expectedIdentity: ScopedIdentity,
expectedScope: OnboardingScope,
privacyCapability: boolean,
): SuggestionEvaluation | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id", "trigger",
"privacy", "claim_id", "issued_at", "suggestion", "state",
]) || value.schema !== "hux.suggestion_evaluation.v1" || value.api_version !== "hux.v1")
return null;
const identity = normalizeIdentity(value.identity);
const trigger = normalizeTrigger(value.trigger);
const privacy = asRecord(value.privacy);
if (!identity || !sameIdentity(identity, expectedIdentity) ||
value.session_id !== expectedScope.sessionId ||
value.conversation_id !== expectedScope.conversationId || !trigger ||
!sameTrigger(trigger, expectedScope.trigger) || !privacy ||
!exactKeys(privacy, ["schema", "no_store", "memory_suggestions_allowed"]) ||
privacy.schema !== "hux.onboarding_privacy.v1" ||
typeof privacy.no_store !== "boolean" ||
typeof privacy.memory_suggestions_allowed !== "boolean") return null;
if (privacy.no_store || value.suggestion === null || value.state === null ||
value.claim_id === null) return null;
if (!isId(value.claim_id, "clm") || !isUtc(value.issued_at)) return null;
const suggestion = normalizeSuggestion(value.suggestion as RawSuggestion, expectedScope.trigger);
if (!suggestion || (suggestion.action.type === "open_memory" &&
(!privacyCapability || privacy.memory_suggestions_allowed !== true))) return null;
const state = normalizeState(value.state as RawSuggestionState, suggestion, identity.userRef);
if (!state) return null;
return {
schema: "hux.suggestion_evaluation.v1", identity,
scope: expectedScope, claimId: value.claim_id, issuedAt: value.issued_at,
privacy: {
schema: "hux.onboarding_privacy.v1", noStore: false,
memorySuggestionsAllowed: privacy.memory_suggestions_allowed,
},
suggestion, state,
};
}
export function buildActionIntent(
evaluation: SuggestionEvaluation,
): OnboardingActionIntent | null {
const {action} = evaluation.suggestion;
const payload = action.payload || {};
if (action.type === "open_mode") return {type: action.type, ...(payload.mode ? {mode: payload.mode} : {})};
if (action.type === "create_project") return {type: action.type,
conversationId: evaluation.scope.conversationId,
...(payload.prefill_title ? {prefillTitle: payload.prefill_title} : {})};
if (action.type === "open_memory") return {type: action.type, view: "suggestions"};
if (action.type === "open_artifacts") return {type: action.type, view: "current"};
if (action.type === "start_workflow") return {type: action.type,
intent: payload.intent as "schedule_weekly" | "save_reusable"};
return null;
}

View File

@ -0,0 +1,98 @@
/** Identity, scope, trigger and time guards for HUX-09. */
import type {
HuxSurface,
OnboardingScope,
RawIdentity,
ScopedIdentity,
SuggestionContext,
SuggestionTrigger,
} from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
const CONTEXTS = new Set<SuggestionContext>([
"first_session", "empty_project", "after_artifact", "after_research",
"after_approval", "idle",
]);
export function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const allowed = new Set([...required, ...optional]);
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key));
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ?
value as Record<string, unknown> : null;
}
export function isId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) &&
!Number.isNaN(Date.parse(value));
}
export function isSafeText(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max &&
!CONTROL.test(value);
}
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
const value = raw as RawIdentity | null;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
if (!exactKeys(value as Record<string, unknown>, ["tenant_ref", "user_ref", "surface"]))
return null;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
typeof value.surface !== "string" || !SURFACES.has(value.surface as HuxSurface))
return null;
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
surface: value.surface as HuxSurface};
}
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function normalizeTrigger(raw: unknown): SuggestionTrigger | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, ["surface", "context"]) ||
!SURFACES.has(value.surface as HuxSurface) ||
!CONTEXTS.has(value.context as SuggestionContext)) return null;
return {surface: value.surface as HuxSurface, context: value.context as SuggestionContext};
}
export function sameTrigger(left: SuggestionTrigger, right: SuggestionTrigger): boolean {
return left.surface === right.surface && left.context === right.context;
}
export function normalizeScope(raw: OnboardingScope, identity: ScopedIdentity): OnboardingScope | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, ["sessionId", "conversationId", "trigger"]) ||
!isId(value.sessionId, "ses") || !isId(value.conversationId, "conv")) return null;
const trigger = normalizeTrigger(value.trigger);
if (!trigger || trigger.surface !== identity.surface) return null;
return {sessionId: value.sessionId, conversationId: value.conversationId, trigger};
}
export function scopedSuggestionPath(base: string, suggestionId: string): string {
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) ||
base.includes("..") || !isId(suggestionId, "sug")) {
throw new TypeError("Onboarding path is not safely scoped");
}
return `${base.replace(/\/$/, "")}/${encodeURIComponent(suggestionId)}/decisions`;
}

View File

@ -0,0 +1,124 @@
/* Component-scoped HUX-09 presentation; importing it cannot recolor the page. */
.hux-onboarding {
background: color-mix(in srgb, #22d3ee 4%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 22%, currentColor 8%);
border-radius: 0.8rem;
color: inherit;
container-type: inline-size;
display: grid;
gap: 0.7rem 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 0.8rem 0.9rem;
}
.hux-onboarding-copy {
min-width: 0;
}
.hux-onboarding-kicker {
color: color-mix(in srgb, currentColor 60%, #22d3ee);
display: block;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.hux-onboarding h2 {
font-size: 0.8125rem;
font-weight: 680;
line-height: 1.35;
margin: 0.15rem 0 0;
}
.hux-onboarding-copy > p,
.hux-onboarding-why p {
color: color-mix(in srgb, currentColor 68%, transparent);
font-size: 0.6875rem;
line-height: 1.45;
margin: 0.2rem 0 0;
}
.hux-onboarding-why {
font-size: 0.625rem;
margin-top: 0.45rem;
}
.hux-onboarding-why summary {
color: color-mix(in srgb, currentColor 58%, transparent);
cursor: pointer;
width: fit-content;
}
.hux-onboarding-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
}
.hux-onboarding button {
background: transparent;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 0.55rem;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.6875rem;
min-height: 2rem;
padding: 0.35rem 0.6rem;
}
.hux-onboarding .hux-onboarding-primary {
background: color-mix(in srgb, #22d3ee 12%, transparent);
border-color: color-mix(in srgb, #22d3ee 48%, currentColor);
font-weight: 650;
}
.hux-onboarding .hux-onboarding-never {
border-color: transparent;
color: color-mix(in srgb, currentColor 58%, transparent);
}
.hux-onboarding button:focus-visible,
.hux-onboarding summary:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
}
.hux-onboarding button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.hux-onboarding-status,
.hux-onboarding-error {
font-size: 0.625rem;
grid-column: 1 / -1;
margin: 0;
}
.hux-onboarding-status:empty {
display: none;
}
.hux-onboarding-error {
color: color-mix(in srgb, currentColor 82%, #fb7185);
}
@container (max-width: 38rem) {
.hux-onboarding {
grid-template-columns: 1fr;
}
.hux-onboarding-controls {
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-onboarding * {
scroll-behavior: auto;
}
}

View File

@ -0,0 +1,142 @@
/** Scoped types for the default-off HUX-09 contextual onboarding surface. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type SuggestionContext =
| "first_session"
| "empty_project"
| "after_artifact"
| "after_research"
| "after_approval"
| "idle";
export type SuggestionKind = "workflow" | "project" | "feature" | "tip";
export type SuggestionActionType =
| "open_mode"
| "create_project"
| "open_memory"
| "open_artifacts"
| "start_workflow"
| "none";
export type SuggestionDecision = "dismiss" | "never_again" | "acted";
export interface ScopedIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawIdentity {
tenant_ref?: unknown;
user_ref?: unknown;
surface?: unknown;
}
export interface SuggestionTrigger {
surface: HuxSurface;
context: SuggestionContext;
}
export interface OnboardingScope {
sessionId: string;
conversationId: string;
trigger: SuggestionTrigger;
}
export interface RawSuggestion {
schema?: unknown;
id?: unknown;
kind?: unknown;
trigger?: unknown;
title?: unknown;
body?: unknown;
action?: unknown;
priority?: unknown;
suppression?: unknown;
}
export interface OnboardingSuggestion {
schema: "hux.suggestion.v1";
id: string;
kind: SuggestionKind;
trigger: SuggestionTrigger;
title: string;
body: string;
action: {
type: SuggestionActionType;
payload?: Readonly<Record<string, string>>;
};
priority: number;
suppression: {
dismissable: true;
maxShows: number;
cooldownSeconds: number;
neverAgainSupported: true;
};
}
export interface RawSuggestionState {
schema?: unknown;
owner?: unknown;
suggestion_id?: unknown;
shows?: unknown;
last_shown_at?: unknown;
dismissed_at?: unknown;
acted_at?: unknown;
never_again?: unknown;
}
export interface SuggestionState {
schema: "hux.suggestion_state.v1";
owner: string;
suggestionId: string;
shows: number;
lastShownAt: string;
dismissedAt?: string;
actedAt?: string;
neverAgain: boolean;
}
export interface PrivacySignal {
schema: "hux.onboarding_privacy.v1";
noStore: boolean;
memorySuggestionsAllowed: boolean;
}
export interface SuggestionEvaluation {
schema: "hux.suggestion_evaluation.v1";
identity: ScopedIdentity;
scope: OnboardingScope;
claimId: string;
issuedAt: string;
privacy: PrivacySignal;
suggestion: OnboardingSuggestion;
state: SuggestionState;
}
export type OnboardingActionIntent =
| {type: "open_mode"; mode?: string}
| {type: "create_project"; conversationId: string; prefillTitle?: string}
| {type: "open_memory"; view: "suggestions"}
| {type: "open_artifacts"; view: "current"}
| {type: "start_workflow"; intent: "schedule_weekly" | "save_reusable"};
export interface HuxFoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
export interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
export interface OnboardingClient {
enabled(): boolean;
evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null>;
decide(
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): Promise<SuggestionState>;
}

View File

@ -0,0 +1,252 @@
/** Accessible, default-off Projects and Conversations workspace for HUX-03. */
import { Folder, GitBranch, Paperclip, Pin, Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { attachmentViews, branchLineage, projectsEnabled } from "./model.ts";
import type {
ArtifactSummary,
Conversation,
Project,
} from "./types.ts";
interface OrganizationWorkspaceProps {
flags?: Iterable<string>;
projects: readonly Project[];
conversations: readonly Conversation[];
artifacts?: readonly ArtifactSummary[];
searchResults?: readonly Conversation[];
nextSearchCursor?: string | null;
busy?: boolean;
error?: string | null;
onSearch?: (query: string, cursor: string | null) => void;
onRenameProject?: (project: Project, name: string) => void;
onRenameConversation?: (conversation: Conversation, title: string) => void;
onUpdateProjectTags?: (project: Project, tags: string[]) => void;
onUpdateConversationTags?: (conversation: Conversation, tags: string[]) => void;
onMoveConversation?: (conversation: Conversation, projectId: string | null) => void;
onToggleProjectPin?: (project: Project) => void;
onToggleConversationPin?: (conversation: Conversation) => void;
}
function ordered<T extends {pinned: boolean; updatedAt: string}>(items: readonly T[]): T[] {
return [...items].sort((left, right) => Number(right.pinned) - Number(left.pinned) ||
right.updatedAt.localeCompare(left.updatedAt));
}
function Tags({values}: {values: readonly string[]}) {
if (!values.length) return null;
return <ul className="hux-org-tags" aria-label="Tags">
{values.map((tag) => <li key={tag}>{tag}</li>)}
</ul>;
}
function EditableTitle({label, value, maxLength, onSave}: {
label: string;
value: string;
maxLength: number;
onSave?: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!onSave) return <span>{value}</span>;
if (!editing) return <button className="hux-org-title-button" type="button"
onClick={() => { setDraft(value); setEditing(true); }} aria-label={`${label}: ${value}. Rename`}>
Rename
</button>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const next = draft.trim();
if (next && next !== value) onSave(next);
setEditing(false);
}}>
<label><span className="sr-only">{label}</span>
<input value={draft} maxLength={maxLength} autoFocus onChange={(event) => setDraft(event.target.value)} />
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function EditableTags({label, values, onSave}: {
label: string;
values: readonly string[];
onSave?: (values: string[]) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(values.join(", "));
const [invalid, setInvalid] = useState(false);
if (!onSave) return <Tags values={values} />;
if (!editing) return <div><Tags values={values} /><button type="button"
onClick={() => { setDraft(values.join(", ")); setInvalid(false); setEditing(true); }}>
Edit tags
</button></div>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const tags = draft.split(",").map((tag) => tag.trim()).filter(Boolean);
const valid = tags.length <= 32 && new Set(tags).size === tags.length &&
tags.every((tag) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(tag));
setInvalid(!valid);
if (valid) { onSave(tags); setEditing(false); }
}}>
<label><span>{label}</span>
<input value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
placeholder="planning, research" />
</label>
{invalid && <span role="alert">Use up to 32 lowercase tags separated by commas.</span>}
<button type="submit">Save tags</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function ProjectList({projects, selectedId, onSelect, onRename, onTags, onPin}: {
projects: readonly Project[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onRename?: OrganizationWorkspaceProps["onRenameProject"];
onTags?: OrganizationWorkspaceProps["onUpdateProjectTags"];
onPin?: OrganizationWorkspaceProps["onToggleProjectPin"];
}) {
return <nav className="hux-org-projects" aria-label="Project folders">
<h2><Folder aria-hidden /> Projects</h2>
<ul>
<li><button type="button" aria-current={selectedId === null ? "page" : undefined}
onClick={() => onSelect(null)}>All conversations</button></li>
{ordered(projects).map((project) => <li key={project.id} data-pinned={project.pinned}>
<button className="hux-org-folder" type="button"
aria-current={selectedId === project.id ? "page" : undefined}
onClick={() => onSelect(project.id)}>{project.name}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${project.pinned ? "Unpin" : "Pin"} ${project.name}`}
aria-pressed={project.pinned} onClick={() => onPin(project)}><Pin aria-hidden /></button>}
{onRename && <EditableTitle label="Project name" value={project.name} maxLength={120}
onSave={(name) => onRename(project, name)} />}
<EditableTags label="Project tags" values={project.tags}
onSave={onTags ? (tags) => onTags(project, tags) : undefined} />
</li>)}
</ul>
</nav>;
}
function Lineage({conversation, conversations}: {
conversation: Conversation;
conversations: readonly Conversation[];
}) {
const nodes = branchLineage(conversation.id, conversations);
return <section className="hux-org-lineage" aria-labelledby="hux-org-lineage-title">
<h3 id="hux-org-lineage-title"><GitBranch aria-hidden /> Branch lineage</h3>
<ol>
{nodes.map((node) => <li key={`${node.conversationId}-${node.depth}`}
data-missing={node.missing} data-cycle={node.cycle}>
<span>{node.title}</span>
{node.branchPointMessageId && <small>branched at message {node.branchPointMessageId}</small>}
</li>)}
</ol>
</section>;
}
function Attachments({conversation, artifacts}: {
conversation: Conversation;
artifacts: readonly ArtifactSummary[];
}) {
const items = attachmentViews(conversation, artifacts);
return <section className="hux-org-attachments" aria-labelledby="hux-org-attachments-title">
<h3 id="hux-org-attachments-title"><Paperclip aria-hidden /> Attached artifacts</h3>
{items.length ? <ul>{items.map((item) => <li key={item.id} data-missing={item.missing}>
<span>{item.title}</span><small>{item.missing ? "Still attached; details unavailable" : item.kind}</small>
</li>)}</ul> : <p>No artifacts attached.</p>}
</section>;
}
function ConversationList({items, projects, selected, onSelect, onRename, onTags, onMove, onPin}: {
items: readonly Conversation[];
projects: readonly Project[];
selected: string | null;
onSelect: (id: string) => void;
onRename?: OrganizationWorkspaceProps["onRenameConversation"];
onTags?: OrganizationWorkspaceProps["onUpdateConversationTags"];
onMove?: OrganizationWorkspaceProps["onMoveConversation"];
onPin?: OrganizationWorkspaceProps["onToggleConversationPin"];
}) {
return <ul className="hux-org-conversations" aria-label="Conversations">
{ordered(items).map((conversation) => <li key={conversation.id}
data-selected={selected === conversation.id} data-pinned={conversation.pinned}>
<div className="hux-org-conversation-title">
<button type="button" onClick={() => onSelect(conversation.id)}
aria-pressed={selected === conversation.id}>{conversation.title}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${conversation.pinned ? "Unpin" : "Pin"} ${conversation.title}`}
aria-pressed={conversation.pinned} onClick={() => onPin(conversation)}><Pin aria-hidden /></button>}
</div>
{onRename && <EditableTitle label="Conversation title" value={conversation.title} maxLength={200}
onSave={(title) => onRename(conversation, title)} />}
<EditableTags label="Conversation tags" values={conversation.tags}
onSave={onTags ? (tags) => onTags(conversation, tags) : undefined} />
<a className="hux-org-resume" href={`/session/${encodeURIComponent(conversation.id)}`}>
Resume conversation
</a>
{onMove && <label className="hux-org-move">Move to
<select value={conversation.projectId || ""}
onChange={(event) => onMove(conversation, event.target.value || null)}>
<option value="">No project</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>}
</li>)}
</ul>;
}
/** Render nothing unless the negotiated hux.foundation and hux.projects flags are enabled. */
export function OrganizationWorkspace(props: OrganizationWorkspaceProps) {
const [projectId, setProjectId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const enabled = projectsEnabled(props.flags);
const baseItems = props.searchResults || props.conversations;
const items = useMemo(() => projectId === null ? baseItems :
baseItems.filter((item) => item.projectId === projectId), [baseItems, projectId]);
const selected = props.conversations.find((item) => item.id === selectedId) ||
items[0] || null;
if (!enabled) return null;
function submitSearch(event: FormEvent) {
event.preventDefault();
const value = query.trim();
if (value) props.onSearch?.(value, null);
}
return <section className="hux-organization" aria-label="Projects and conversations" aria-busy={props.busy}>
<ProjectList projects={props.projects} selectedId={projectId} onSelect={setProjectId}
onRename={props.onRenameProject} onTags={props.onUpdateProjectTags}
onPin={props.onToggleProjectPin} />
<main className="hux-org-main">
<header><h2>Conversations</h2>
<form role="search" onSubmit={submitSearch}>
<label htmlFor="hux-org-search">Search conversations, messages, projects, and artifacts</label>
<div><Search aria-hidden /><input id="hux-org-search" type="search" maxLength={200}
value={query} onChange={(event) => setQuery(event.target.value)} />
<button type="submit" disabled={!query.trim() || props.busy}>Search</button></div>
</form>
</header>
{props.error && <p className="hux-org-error" role="alert">{props.error}</p>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Loading conversations" : `${items.length} conversations shown`}
</p>
<ConversationList items={items} projects={props.projects} selected={selected?.id || null}
onSelect={setSelectedId} onRename={props.onRenameConversation}
onTags={props.onUpdateConversationTags} onMove={props.onMoveConversation}
onPin={props.onToggleConversationPin} />
{!items.length && <p className="hux-org-empty">No matching conversations.</p>}
{props.nextSearchCursor && props.onSearch && <button className="hux-org-more" type="button"
disabled={props.busy} onClick={() => props.onSearch?.(query.trim(), props.nextSearchCursor || null)}>
Load more results
</button>}
</main>
<aside className="hux-org-details" aria-label="Conversation details">
{selected ? <><Lineage conversation={selected} conversations={props.conversations} />
<Attachments conversation={selected} artifacts={props.artifacts || []} /></> :
<p>Select a conversation to inspect its lineage and attachments.</p>}
</aside>
</section>;
}

View File

@ -0,0 +1,253 @@
/** Same-origin, capability-gated transport for HUX-03. */
import { ACCEPT, API_VERSION, RESPONSE_SCHEMAS } from "./contracts.ts";
import { normalizeConversation, normalizeProject, preservesConversation } from "./model.ts";
import {
boundedLimit,
isOpaqueId,
isUtc,
normalizeIdentity,
safeCursor,
safeTags,
sameIdentity,
scopedPath,
} from "./security.ts";
import type {
Conversation,
ConversationPatch,
Page,
Project,
ProjectPatch,
RawConversation,
RawEnvelope,
RawProject,
ScopedIdentity,
SearchFilters,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
interface ClientOptions {
client: FoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
interface RawMutationEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
item?: unknown;
}
export class OrganizationContractError extends Error {
constructor(message: string) {
super(message);
this.name = "OrganizationContractError";
}
}
function assertAvailable(client: FoundationClient): void {
if (client.apiVersion !== API_VERSION || !client.enabled("hux.foundation") ||
!client.enabled("hux.projects")) {
throw new OrganizationContractError("Project organization is not enabled");
}
}
function assertEnvelope(raw: unknown, schema: string, expected: ScopedIdentity): RawEnvelope {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new OrganizationContractError("Invalid organization response");
}
const value = raw as RawEnvelope;
const identity = normalizeIdentity(value.identity);
if (value.schema !== schema || value.api_version !== API_VERSION || !identity ||
!sameIdentity(identity, expected)) {
throw new OrganizationContractError("Organization response crossed its scope");
}
return value;
}
function pagination(raw: RawEnvelope): Pick<Page<never>, "nextCursor" | "total"> {
const cursor = safeCursor(raw.next_cursor);
if (cursor === undefined || (raw.total !== null && raw.total !== undefined &&
(!Number.isSafeInteger(raw.total) || (raw.total as number) < 0))) {
throw new OrganizationContractError("Invalid organization pagination");
}
return {nextCursor: cursor || null, total: raw.total as number | null | undefined ?? null};
}
function pageQuery(cursor: string | null | undefined, limit: number | undefined): URLSearchParams {
const query = new URLSearchParams();
const safe = safeCursor(cursor);
if (safe === undefined) throw new TypeError("Invalid organization cursor");
if (safe) query.set("cursor", safe);
query.set("limit", String(boundedLimit(limit)));
return query;
}
function projectPage(raw: unknown, expected: ScopedIdentity): Page<Project> {
const value = assertEnvelope(raw, RESPONSE_SCHEMAS.projects, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Project page exceeds its bound");
}
const items = value.items.map((item) => normalizeProject(item as RawProject, expected.userRef));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid project record");
}
return {...pagination(value), items: items as Project[]};
}
function conversationPage(
raw: unknown,
schema: string,
expected: ScopedIdentity,
projectIds: ReadonlySet<string>,
): Page<Conversation> {
const value = assertEnvelope(raw, schema, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Conversation page exceeds its bound");
}
const items = value.items.map((item) =>
normalizeConversation(item as RawConversation, expected.userRef, projectIds));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid conversation record");
}
return {...pagination(value), items: items as Conversation[]};
}
function mutationItem(
raw: unknown,
schema: string,
expected: ScopedIdentity,
): unknown {
const value = assertEnvelope(raw, schema, expected) as RawMutationEnvelope;
if (!("item" in value) || value.item === undefined) {
throw new OrganizationContractError("Mutation response has no item");
}
return value.item;
}
export function createOrganizationClient(options: ClientOptions) {
const client = options.client;
const fetcher = options.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Organization client requires HUX and fetch clients");
const identity = client.identity;
async function request(path: string, init: Record<string, unknown> = {}): Promise<unknown> {
assertAvailable(client);
const response = await fetcher(client.endpoint(path), {
cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})},
...init,
});
if (!response.ok) throw new OrganizationContractError(
response.status === 409 ? "This item changed; refresh before trying again" :
`Organization request failed (${response.status})`,
);
return response.json();
}
async function listProjects(cursor?: string | null, limit?: number): Promise<Page<Project>> {
const query = pageQuery(cursor, limit);
return projectPage(await request(`/projects?${query}`), identity);
}
async function listConversations(
projectId: string,
projectIds: ReadonlySet<string>,
cursor?: string | null,
limit?: number,
): Promise<Page<Conversation>> {
if (!projectIds.has(projectId)) throw new TypeError("Project is outside this workspace");
const path = `${scopedPath("/projects", projectId)}/conversations?${pageQuery(cursor, limit)}`;
const page = conversationPage(await request(path), RESPONSE_SCHEMAS.conversations, identity, projectIds);
if (page.items.some((item) => item.projectId !== projectId)) {
throw new OrganizationContractError("Conversation crossed its project boundary");
}
return page;
}
async function searchConversations(
filters: SearchFilters,
projectIds: ReadonlySet<string>,
): Promise<Page<Conversation>> {
const text = typeof filters.query === "string" ? filters.query.trim() : "";
if (!text || text.length > 200) throw new TypeError("Search query must be 1 to 200 characters");
const query = pageQuery(filters.cursor, filters.limit);
query.set("q", text);
if (filters.projectId) {
if (!projectIds.has(filters.projectId)) throw new TypeError("Search project is outside this workspace");
query.set("project_id", filters.projectId);
}
const tags = safeTags(filters.tags || []);
if (tags === null) throw new TypeError("Search tags are invalid");
tags.forEach((tag) => query.append("tag", tag));
if (filters.pinned !== undefined) query.set("pinned", String(filters.pinned));
return conversationPage(await request(`/conversations/search?${query}`),
RESPONSE_SCHEMAS.search, identity, projectIds);
}
async function updateProject(before: Project, patch: ProjectPatch): Promise<Project> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt)) {
throw new TypeError("Project mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const name = patch.name === undefined ? undefined : patch.name.trim();
if (tags === null || (name !== undefined && (!name || name.length > 120))) {
throw new TypeError("Project mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(name !== undefined ? {name} : {}), ...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/projects", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.projectMutation, identity);
const item = normalizeProject(raw as RawProject, identity.userRef);
if (!item || item.id !== before.id) throw new OrganizationContractError("Project identity changed during mutation");
return item;
}
async function updateConversation(
before: Conversation,
patch: ConversationPatch,
projectIds: ReadonlySet<string>,
): Promise<Conversation> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt) ||
(before.projectId !== null && !projectIds.has(before.projectId)) ||
(patch.projectId !== undefined && patch.projectId !== null && !projectIds.has(patch.projectId))) {
throw new TypeError("Conversation mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const title = patch.title === undefined ? undefined : patch.title.trim();
if (tags === null || (title !== undefined && (!title || title.length > 200))) {
throw new TypeError("Conversation mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(title !== undefined ? {title} : {}),
...(patch.projectId !== undefined ? {project_id: patch.projectId} : {}),
...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/conversations", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.conversationMutation, identity);
const item = normalizeConversation(raw as RawConversation, identity.userRef, projectIds);
const intendedProject = patch.projectId === undefined ? before.projectId : patch.projectId;
if (!item || item.projectId !== intendedProject || !preservesConversation(before, item)) {
throw new OrganizationContractError("Conversation lineage or attachments changed during mutation");
}
return item;
}
return Object.freeze({listConversations, listProjects, searchConversations,
updateConversation, updateProject});
}

View File

@ -0,0 +1,38 @@
/** Exact same-origin backend contracts required by the HUX-03 frontend. */
export const API_VERSION = "hux.v1";
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
export const RESPONSE_SCHEMAS = Object.freeze({
projects: "hux.projects.page.v1",
conversations: "hux.conversations.page.v1",
search: "hux.conversations.search.v1",
projectMutation: "hux.project.response.v1",
conversationMutation: "hux.conversation.response.v1",
});
/**
* GET /hux/v1/projects?cursor=&limit=
* GET /hux/v1/projects/{prj_id}/conversations?cursor=&limit=
* GET /hux/v1/conversations/search?q=&project_id=&tag=&pinned=&cursor=&limit=
* PATCH /hux/v1/projects/{prj_id}
* PATCH /hux/v1/conversations/{conv_id}
*
* Every response is JSON with api_version="hux.v1", its operation-specific
* schema, and identity={tenant_ref,user_ref,surface}. Page responses contain
* items, next_cursor (opaque or null), and total (non-negative integer or
* null). Mutation responses contain item. PATCH bodies use only name/title,
* project_id, tags, pinned, and required expected_updated_at for optimistic
* concurrency. Identity is derived from the authenticated server session and
* MUST NOT be accepted from a query, path, body, or browser storage. Moves MUST
* preserve conversation id, branch, and artifact_ids atomically. A 409 means
* stale expected_updated_at; 404 is not converted into an unscoped lookup.
*/
export const BACKEND_ENDPOINTS = Object.freeze({
projects: "/projects",
projectConversations: "/projects/{prj_id}/conversations",
search: "/conversations/search",
project: "/projects/{prj_id}",
conversation: "/conversations/{conv_id}",
});

View File

@ -0,0 +1,14 @@
export { createOrganizationClient, OrganizationContractError } from "./client.ts";
export { OrganizationWorkspace } from "./OrganizationWorkspace.tsx";
export {
attachmentViews,
branchLineage,
normalizeConversation,
normalizeProject,
normalizeSnapshot,
preservesConversation,
projectsEnabled,
} from "./model.ts";
export { BACKEND_ENDPOINTS, RESPONSE_SCHEMAS } from "./contracts.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,165 @@
/** Schema-aligned normalization and lineage invariants for HUX-03. */
import { isOpaqueId, isUtc, safeTags, safeText } from "./security.ts";
import type {
ArtifactSummary,
AttachmentView,
Conversation,
LineageNode,
OrganizationSnapshot,
Project,
RawConversation,
RawProject,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
export function projectsEnabled(flags?: Iterable<string>): boolean {
const enabled = new Set(flags || []);
return enabled.has(FOUNDATION_FLAG) && enabled.has(PROJECTS_FLAG);
}
export function normalizeProject(raw: RawProject, owner: string): Project | null {
const tags = safeTags(raw.tags);
if (raw.schema !== "hux.project.v1" || !isOpaqueId(raw.id, "prj") ||
raw.owner !== owner || tags === null || typeof raw.pinned !== "boolean" ||
typeof raw.archived !== "boolean" || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.default_mode !== undefined &&
(typeof raw.default_mode !== "string" || !MODES.has(raw.default_mode)))) return null;
const name = safeText(raw.name, "", 120);
if (!name) return null;
return {
id: raw.id, owner, name,
description: safeText(raw.description, "", 2000), tags,
pinned: raw.pinned, archived: raw.archived, updatedAt: raw.updated_at,
};
}
function normalizeBranch(raw: unknown): Conversation["branch"] | undefined {
if (raw === undefined) return null;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
if (!isOpaqueId(value.parent_conversation_id, "conv") ||
typeof value.branch_point_message_id !== "string") return undefined;
const messageId = safeText(value.branch_point_message_id, "", 120);
return messageId ? {
parentConversationId: value.parent_conversation_id,
branchPointMessageId: messageId,
} : undefined;
}
export function normalizeConversation(
raw: RawConversation,
owner: string,
projectIds: ReadonlySet<string>,
): Conversation | null {
const tags = safeTags(raw.tags);
const branch = normalizeBranch(raw.branch);
const artifacts = Array.isArray(raw.artifact_ids) ? raw.artifact_ids : null;
const projectId = raw.project_id === undefined ? null : raw.project_id;
if (raw.schema !== "hux.conversation.v1" || !isOpaqueId(raw.id, "conv") ||
raw.owner !== owner || tags === null || branch === undefined ||
typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" ||
!artifacts || artifacts.length > 500 || new Set(artifacts).size !== artifacts.length ||
artifacts.some((id) => !isOpaqueId(id, "art")) || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.last_message_at !== undefined &&
!isUtc(raw.last_message_at)) || (raw.mode !== undefined &&
(typeof raw.mode !== "string" || !MODES.has(raw.mode))) ||
(projectId !== null && (!isOpaqueId(projectId, "prj") || !projectIds.has(projectId)))) return null;
const title = safeText(raw.title, "", 200);
if (!title || branch?.parentConversationId === raw.id) return null;
return {
id: raw.id, owner, projectId, title, tags, pinned: raw.pinned,
archived: raw.archived, artifactIds: [...artifacts] as string[], branch,
lastMessageAt: raw.last_message_at as string | undefined || null,
updatedAt: raw.updated_at,
};
}
/** Reject cross-owner/project records instead of silently re-homing them. */
export function normalizeSnapshot(
rawProjects: readonly RawProject[],
rawConversations: readonly RawConversation[],
owner: string,
): OrganizationSnapshot {
const seenProjects = new Set<string>();
const projects = rawProjects.slice(0, 100).map((item) => normalizeProject(item, owner))
.filter((item): item is Project => {
if (!item || seenProjects.has(item.id)) return false;
seenProjects.add(item.id);
return true;
});
const projectIds = new Set(projects.map((item) => item.id));
const seenConversations = new Set<string>();
const conversations = rawConversations.slice(0, 500)
.map((item) => normalizeConversation(item, owner, projectIds))
.filter((item): item is Conversation => {
if (!item || seenConversations.has(item.id)) return false;
seenConversations.add(item.id);
return true;
});
return {
projects, conversations,
rejected: Math.max(0, rawProjects.length - projects.length) +
Math.max(0, rawConversations.length - conversations.length),
};
}
/** Missing metadata stays visible as an attachment placeholder. */
export function attachmentViews(
conversation: Conversation,
summaries: readonly ArtifactSummary[],
): AttachmentView[] {
const byId = new Map(summaries.filter((item) =>
isOpaqueId(item.id, "art")).slice(0, 500).map((item) => [item.id, item]));
return conversation.artifactIds.map((id) => {
const found = byId.get(id);
return found ? {
id, title: safeText(found.title, "Untitled artifact", 200),
kind: safeText(found.kind, "artifact", 40), missing: false,
} : {id, title: "Attachment details unavailable", kind: "artifact", missing: true};
});
}
/** Build bounded ancestry with explicit missing/cycle nodes, never a fake root. */
export function branchLineage(
selectedId: string,
conversations: readonly Conversation[],
): LineageNode[] {
if (!isOpaqueId(selectedId, "conv")) return [];
const byId = new Map(conversations.slice(0, 500).map((item) => [item.id, item]));
const result: LineageNode[] = [];
const visited = new Set<string>();
let currentId: string | null = selectedId;
while (currentId && result.length < 64) {
if (visited.has(currentId)) {
result.push({conversationId: currentId, title: "Circular branch reference", parentConversationId: null,
branchPointMessageId: null, depth: result.length, missing: false, cycle: true});
break;
}
visited.add(currentId);
const item = byId.get(currentId);
if (!item) {
result.push({conversationId: currentId, title: "Earlier conversation unavailable",
parentConversationId: null, branchPointMessageId: null, depth: result.length,
missing: true, cycle: false});
break;
}
result.push({conversationId: item.id, title: item.title,
parentConversationId: item.branch?.parentConversationId || null,
branchPointMessageId: item.branch?.branchPointMessageId || null,
depth: result.length, missing: false, cycle: false});
currentId = item.branch?.parentConversationId || null;
}
return result.reverse().map((item, depth) => ({...item, depth}));
}
/** A move/rename may not change stable lineage or orphan any attachment. */
export function preservesConversation(before: Conversation, after: Conversation): boolean {
return before.id === after.id && before.owner === after.owner &&
JSON.stringify(before.branch) === JSON.stringify(after.branch) &&
before.artifactIds.length === after.artifactIds.length &&
before.artifactIds.every((id, index) => after.artifactIds[index] === id);
}

View File

@ -0,0 +1,78 @@
/** Identity, text, URL, and collection guards for HUX-03. */
import type { RawIdentity, ScopedIdentity } from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const CURSOR = /^[A-Za-z0-9._~-]{1,256}$/;
const TAG = /^[a-z0-9][a-z0-9-]{0,39}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/g;
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) &&
!Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit: number): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, limit - 1).trimEnd()}…`;
}
export function safeTags(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > 32) return null;
const tags = value.filter((tag): tag is string => typeof tag === "string");
if (tags.length !== value.length || tags.some((tag) => !TAG.test(tag))) return null;
const unique = [...new Set(tags)];
return unique.length === tags.length ? unique : null;
}
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
const value = raw as RawIdentity | null;
if (!value || typeof value !== "object") return null;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref)) return null;
if (typeof value.user_ref !== "string" || !USER.test(value.user_ref)) return null;
if (typeof value.surface !== "string" || !SURFACES.has(value.surface)) return null;
return {
tenantRef: value.tenant_ref,
userRef: value.user_ref,
surface: value.surface as ScopedIdentity["surface"],
};
}
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function safeCursor(value: unknown): string | null | undefined {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" && CURSOR.test(value) ? value : undefined;
}
export function scopedPath(base: string, ...parts: string[]): string {
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..")) {
throw new TypeError("HUX base URL must be a same-origin path");
}
const cleanBase = base.replace(/\/$/, "");
const encoded = parts.map((part) => {
if (!isOpaqueId(part)) throw new TypeError("HUX path identifier is invalid");
return encodeURIComponent(part);
});
return [cleanBase, ...encoded].join("/");
}
export function boundedLimit(value: unknown, fallback = 40): number {
return Number.isInteger(value) && (value as number) > 0 ?
Math.min(value as number, 100) : fallback;
}

View File

@ -0,0 +1,89 @@
.hux-organization {
display: grid;
grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr) minmax(14rem, 1fr);
gap: 0.75rem;
color: var(--hux-fg, #dbe8ef);
}
.hux-organization > * {
min-width: 0;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.8rem;
padding: 0.8rem;
background: color-mix(in srgb, var(--hux-bg, #0b121c) 94%, transparent);
}
.hux-organization h2,
.hux-organization h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.65rem;
}
.hux-organization svg { width: 1rem; height: 1rem; }
.hux-organization ul,
.hux-organization ol { margin: 0; padding: 0; list-style: none; }
.hux-org-projects li,
.hux-org-conversations > li,
.hux-org-lineage li,
.hux-org-attachments li { margin-top: 0.45rem; }
.hux-org-projects button[aria-current="page"],
.hux-org-conversations li[data-selected="true"] {
color: var(--hux-accent, #22d3ee);
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 9%, transparent);
}
.hux-org-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.hux-org-tags li {
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.67rem;
}
.hux-org-conversation-title,
.hux-org-main form div { display: flex; gap: 0.4rem; align-items: center; }
.hux-org-icon { padding: 0.2rem; }
.hux-org-icon[aria-pressed="false"] { opacity: 0.45; }
.hux-org-resume,
.hux-org-move { display: block; margin-top: 0.35rem; font-size: 0.75rem; }
.hux-org-move select { margin-left: 0.4rem; }
.hux-org-rename { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.hux-org-lineage li { position: relative; padding-left: 1rem; }
.hux-org-lineage li::before {
position: absolute;
left: 0.2rem;
top: -0.5rem;
bottom: -0.5rem;
width: 1px;
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 50%, transparent);
content: "";
}
.hux-org-lineage li::after {
position: absolute; left: 0; top: 0.45rem; width: 0.45rem; height: 0.45rem;
border-radius: 50%; background: var(--hux-accent, #22d3ee); content: "";
}
.hux-org-lineage small,
.hux-org-attachments small { display: block; opacity: 0.6; }
.hux-org-lineage li[data-missing="true"],
.hux-org-lineage li[data-cycle="true"],
.hux-org-attachments li[data-missing="true"] { color: var(--hux-warning, #fbbf24); }
.hux-org-error { color: var(--hux-danger, #fb7185); }
@media (max-width: 62rem) {
.hux-organization { grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr); }
.hux-org-details { grid-column: 1 / -1; }
}
@media (max-width: 42rem) {
.hux-organization { grid-template-columns: 1fr; }
.hux-org-details { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
.hux-organization * { scroll-behavior: auto; transition: none; }
}

View File

@ -0,0 +1,143 @@
/** Safe frontend and transport types for HUX-03 project organization. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export interface ScopedIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawIdentity {
tenant_ref?: unknown;
user_ref?: unknown;
surface?: unknown;
}
export interface RawProject {
schema?: unknown;
id?: unknown;
owner?: unknown;
name?: unknown;
description?: unknown;
tags?: unknown;
pinned?: unknown;
archived?: unknown;
default_mode?: unknown;
memory_scope_id?: unknown;
created_at?: unknown;
updated_at?: unknown;
}
export interface RawConversation {
schema?: unknown;
id?: unknown;
owner?: unknown;
project_id?: unknown;
title?: unknown;
tags?: unknown;
pinned?: unknown;
archived?: unknown;
mode?: unknown;
branch?: unknown;
artifact_ids?: unknown;
last_message_at?: unknown;
created_at?: unknown;
updated_at?: unknown;
}
export interface Project {
id: string;
owner: string;
name: string;
description: string;
tags: string[];
pinned: boolean;
archived: boolean;
updatedAt: string;
}
export interface ConversationBranch {
parentConversationId: string;
branchPointMessageId: string;
}
export interface Conversation {
id: string;
owner: string;
projectId: string | null;
title: string;
tags: string[];
pinned: boolean;
archived: boolean;
artifactIds: string[];
branch: ConversationBranch | null;
lastMessageAt: string | null;
updatedAt: string;
}
export interface ArtifactSummary {
id: string;
title: string;
kind: string;
}
export interface AttachmentView extends ArtifactSummary {
missing: boolean;
}
export interface LineageNode {
conversationId: string;
title: string;
parentConversationId: string | null;
branchPointMessageId: string | null;
depth: number;
missing: boolean;
cycle: boolean;
}
export interface Page<T> {
items: T[];
nextCursor: string | null;
total: number | null;
}
export interface RawEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
items?: unknown;
next_cursor?: unknown;
total?: unknown;
}
export interface OrganizationSnapshot {
projects: Project[];
conversations: Conversation[];
rejected: number;
}
export interface SearchFilters {
query: string;
projectId?: string | null;
tags?: readonly string[];
pinned?: boolean;
cursor?: string | null;
limit?: number;
}
export interface ConversationPatch {
title?: string;
projectId?: string | null;
tags?: readonly string[];
pinned?: boolean;
expectedUpdatedAt: string;
}
export interface ProjectPatch {
name?: string;
tags?: readonly string[];
pinned?: boolean;
expectedUpdatedAt: string;
}

Some files were not shown because too many files have changed in this diff Show More