From e9d57ea772b6e598e2deecdd2d21dec48ecf0a6d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 13:00:32 +0000 Subject: [PATCH] feat(arch): add contracts skeleton and warn-mode arch-check Give later waves a measurable import/table/path baseline without changing business behavior or occupying the integration slot. Co-authored-by: Cursor --- .doc/system-design/module-boundaries.yaml | 50 +++++++ internal/contracts/contracts_test.go | 46 +++++++ internal/contracts/errors.go | 19 +++ internal/contracts/events.go | 34 +++++ internal/contracts/vocabulary.go | 12 ++ scripts/arch-check.sh | 151 ++++++++++++++++++++++ 6 files changed, 312 insertions(+) create mode 100644 .doc/system-design/module-boundaries.yaml create mode 100644 internal/contracts/contracts_test.go create mode 100644 internal/contracts/errors.go create mode 100644 internal/contracts/events.go create mode 100644 internal/contracts/vocabulary.go create mode 100755 scripts/arch-check.sh diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml new file mode 100644 index 00000000..6e1a391d --- /dev/null +++ b/.doc/system-design/module-boundaries.yaml @@ -0,0 +1,50 @@ +# Wave 0 baseline allowlist. Generated from 2026-09-14 go list, not tightened. +# Target owners are recorded for table/path checks; extra mentions are warnings. + +imports: + agent: config + auth: config + authz: docduty + bizdev: authz,config,ctxmgr,db,docduty,docgov,docrevision,freedoc,kb,projectcontext,projectworkflow,quote,squad,storage,stream,workflowdef + chat: bizdev,coordinator,ctxmgr,docgov,intent,projectinstance,replyexport,squad,stream,taskrun + config: + coordinator: agent,bizdev,ctxmgr,docgov,intent,kb,projectinstance,squad,stream + ctxmgr: docgov,replyexport,storage + db: + docduty: + docgov: docduty,docrevision,fsutil + docrevision: fsutil + freedoc: ctxmgr,docrevision,replyexport + fsutil: "" + intent: bizdev + kb: config + notification: + observability: + projectcontext: squad + projectinstance: docgov,projectworkflow,workflowdef + projectworkflow: auth + quote: + replyexport: + server: agent,auth,authz,bizdev,chat,config,coordinator,ctxmgr,docduty,docgov,docrevision,freedoc,intent,notification,observability,projectinstance,projectworkflow,replyexport,squad,stream,taskrun,upload,workflowdef,workspace + squad: agent,config,quote,stream,template + storage: + stream: + taskrun: bizdev,ctxmgr,projectworkflow,stream + template: + upload: + workflow: + workflowdef: projectworkflow,storage,upload,workflow + workspace: + contracts: + +table_owners: + document_records: docgov + chat_messages: ctxmgr + chat_turns: ctxmgr + freeform_documents: freedoc + task_runs: taskrun + +path_owners: + output.md: squad,workspace + data/dev: config,workspace + workspaces/: squad,workspace diff --git a/internal/contracts/contracts_test.go b/internal/contracts/contracts_test.go new file mode 100644 index 00000000..76eca856 --- /dev/null +++ b/internal/contracts/contracts_test.go @@ -0,0 +1,46 @@ +package contracts + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestContractsHasNoBusinessImports(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + ents, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, ent := range ents { + if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, ent.Name()), nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range f.Imports { + path := strings.Trim(spec.Path.Value, `"`) + if strings.HasPrefix(path, "chat-agent-system/internal/") && path != "chat-agent-system/internal/contracts" { + t.Fatalf("%s imports business package %s", ent.Name(), path) + } + } + } +} + +func TestVocabularyAndErrorCodesStable(t *testing.T) { + if StageV01Triage != "V0.1_triage" || ErrorPlanFrozen != "PLAN_FROZEN" { + t.Fatal("Wave 0 vocabulary drifted") + } + if EventDocumentApproved == "" || EventRunFinalized == "" { + t.Fatal("event names required") + } +} diff --git a/internal/contracts/errors.go b/internal/contracts/errors.go new file mode 100644 index 00000000..27bb5393 --- /dev/null +++ b/internal/contracts/errors.go @@ -0,0 +1,19 @@ +package contracts + +// Stable API error codes. Handlers should eventually emit only these strings +// in the JSON envelope. Wave 0 records the table; it does not change HTTP. +const ( + ErrorDocumentNotFound = "DOCUMENT_NOT_FOUND" + ErrorDocumentContentMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING" + ErrorDocumentContentEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY" + ErrorDocumentContentIntegrity = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH" + ErrorDocumentContentDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT" + ErrorDocumentHeadConflict = "DOCUMENT_HEAD_CONFLICT" + ErrorDocumentEditForbidden = "DOCUMENT_EDIT_FORBIDDEN" + ErrorDocumentBusy = "DOCUMENT_BUSY" + ErrorPlanFrozen = "PLAN_FROZEN" + ErrorWorkflowAccessDenied = "WORKFLOW_ACCESS_DENIED" + ErrorTurnNotCompleted = "TURN_NOT_COMPLETED" + ErrorMessageNotExportable = "MESSAGE_NOT_EXPORTABLE" + ErrorMessageRevisionChanged = "MESSAGE_REVISION_CHANGED" +) diff --git a/internal/contracts/events.go b/internal/contracts/events.go new file mode 100644 index 00000000..442c8e7a --- /dev/null +++ b/internal/contracts/events.go @@ -0,0 +1,34 @@ +package contracts + +import "time" + +// Event is the typed bus payload. Implementations must stay serializable +// and must not embed domain structs from other packages. +type Event interface { + EventName() string + OccurredAt() time.Time +} + +const ( + EventDocumentApproved = "document.approved" + EventDocumentRejected = "document.rejected" + EventDocumentLeaseChanged = "document.lease_changed" + EventRunFinalized = "run.finalized" + EventStageAdvanced = "stage.advanced" + EventStageGateDenied = "stage.gate_denied" + EventMessageRecorded = "message.recorded" + EventNotificationRequested = "notification.requested" +) + +type EventHandler func(ev Event) error + +type EventBus interface { + Publish(ev Event) error + Subscribe(name string, h EventHandler) +} + +// ProgressReporter is the transport-neutral progress port. +// Domain modules must not import internal/stream. +type ProgressReporter interface { + Report(step string, status string, detail map[string]string) +} diff --git a/internal/contracts/vocabulary.go b/internal/contracts/vocabulary.go new file mode 100644 index 00000000..7077240c --- /dev/null +++ b/internal/contracts/vocabulary.go @@ -0,0 +1,12 @@ +package contracts + +// MaturityStage is the shared business-maturity vocabulary. +// Wave 0 only records the values; intent/bizdev still own their local aliases +// until Wave 1 deletes the reverse import. +type MaturityStage string + +const ( + StageV01Triage MaturityStage = "V0.1_triage" + StageV05Feasibility MaturityStage = "V0.5_feasibility" + StageV10Quoting MaturityStage = "V1.0_quoting" +) diff --git a/scripts/arch-check.sh b/scripts/arch-check.sh new file mode 100755 index 00000000..de749722 --- /dev/null +++ b/scripts/arch-check.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Architecture boundary check. Wave 0: warn by default, never rewrite code. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BOUNDARIES="${ROOT}/.doc/system-design/module-boundaries.yaml" +MODE="warn" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="${2:-warn}"; shift 2 ;; + --mode=*) MODE="${1#--mode=}"; shift ;; + *) echo "usage: $0 [--mode warn|block]" >&2; exit 2 ;; + esac +done + +if [[ "${MODE}" != "warn" && "${MODE}" != "block" ]]; then + echo "mode must be warn or block" >&2 + exit 2 +fi + +violations=0 +warn() { + printf 'WARN %s\n' "$1" + violations=$((violations + 1)) +} + +lookup_imports() { + local pkg="$1" + awk -v pkg="$pkg" ' + $1=="imports:" {sec=1; next} + sec && $1 ~ /^(table_owners|path_owners):/ { exit } + sec && $1 ~ /^[A-Za-z0-9_]+:$/ || sec && $1 ~ /^[A-Za-z0-9_]+:/ { + key=$1; sub(":","",key) + $1="" + gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0) + gsub(/"/,"",$0) + if (key==pkg) { print $0; exit } + next + } + ' "${BOUNDARIES}" +} + +lookup_owners() { + local section="$1" key="$2" + awk -v secname="$section" -v key="$key" ' + $0 ~ "^"secname":" {sec=1; next} + sec && $1 ~ /^(imports|table_owners|path_owners):/ && $1 !~ "^"secname":" { exit } + sec && $1 ~ /^[A-Za-z0-9_./]+:/ { + k=$1; sub(":","",k) + $1="" + gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0) + if (k==key) { print $0; exit } + next + } + ' "${BOUNDARIES}" +} + +allowed_has() { + local needle="$1" + local list="$2" + [[ -z "${list}" ]] && return 1 + IFS=',' read -r -a parts <<< "${list}" + for p in "${parts[@]}"; do + p="${p// /}" + [[ "${p}" == "${needle}" ]] && return 0 + done + return 1 +} + +root_pkg() { + local rel="${1#chat-agent-system/internal/}" + echo "${rel%%/*}" +} + +echo "arch-check mode=${MODE}" +echo "boundaries=${BOUNDARIES}" + +while IFS= read -r line; do + [[ -z "${line}" ]] && continue + pkg="$(echo "${line}" | awk '{print $1}')" + root="$(root_pkg "${pkg}")" + [[ "${root}" == "workflow" ]] && continue + allow="$(lookup_imports "${root}")" + deps="$(echo "${line}" | awk '{for(i=2;i<=NF;i++) print $i}')" + for dep in ${deps}; do + [[ "${dep}" != chat-agent-system/internal/* ]] && continue + droot="$(root_pkg "${dep}")" + [[ "${droot}" == "${root}" ]] && continue + if ! allowed_has "${droot}" "${allow}"; then + warn "R-import ${root} -> ${droot} not in baseline allowlist" + fi + done + if [[ "${root}" == "contracts" ]]; then + for dep in ${deps}; do + if [[ "${dep}" == chat-agent-system/internal/* && "${dep}" != chat-agent-system/internal/contracts ]]; then + warn "R1 contracts imports ${dep}" + fi + done + fi +done < <(go list -f '{{.ImportPath}} {{join .Imports " "}}' ./internal/...) + +declare -A seen_table +while IFS= read -r table; do + owners="$(lookup_owners table_owners "${table}")" + while IFS= read -r file; do + [[ -z "${file}" ]] && continue + rel="${file#${ROOT}/internal/}" + pkg="${rel%%/*}" + key="${table}|${pkg}" + [[ -n "${seen_table[$key]+x}" ]] && continue + seen_table[$key]=1 + if ! allowed_has "${pkg}" "${owners}"; then + warn "R5 table ${table} mentioned in ${pkg} (target owner ${owners})" + fi + done < <(rg -l --glob '*.go' --glob '!*_test.go' --fixed-strings "${table}" "${ROOT}/internal" || true) +done <<'TABLES' +document_records +chat_messages +chat_turns +freeform_documents +task_runs +TABLES + +declare -A seen_path +while IFS= read -r lit; do + owners="$(lookup_owners path_owners "${lit}")" + while IFS= read -r file; do + [[ -z "${file}" ]] && continue + rel="${file#${ROOT}/internal/}" + pkg="${rel%%/*}" + key="${lit}|${pkg}" + [[ -n "${seen_path[$key]+x}" ]] && continue + seen_path[$key]=1 + if ! allowed_has "${pkg}" "${owners}"; then + warn "R6 path ${lit} mentioned in ${pkg} (target owner ${owners})" + fi + done < <(rg -l --glob '*.go' --glob '!*_test.go' --fixed-strings "${lit}" "${ROOT}/internal" || true) +done <<'PATHS' +output.md +data/dev +workspaces/ +PATHS + +echo "violations=${violations}" +if [[ "${MODE}" == "block" && "${violations}" -gt 0 ]]; then + echo "arch-check block: failing" >&2 + exit 1 +fi +echo "arch-check ${MODE}: recorded ${violations} baseline findings" +exit 0