laodonge/col-dsh-plugin

col-dsh-plugin

COL (Context Organization Layer) as a DeepSeek Harness / Cordis plugin: ctx.col provides persistent organizational Contexts with replaceable executors - verified write-back, audited history, model-callable tools. Peer dep @deepseek-ai/cordis only.

未分類agentscontextcordis

col-dsh-plugin — COL as a DeepSeek Harness / Cordis plugin

English · 简体中文

Context Organization Layer, as a real DSH service. Offices are persistent organizational slots (positions); Agents/Models are replaceable executors (staff rotate, the position stays); every write-back is verified (3-tier) and every action is audited. Install it into DeepSeek Harness and your agents get a durable, contamination-resistant, auditable "org memory" — plus a model-callable tool to create Offices.

Zero runtime dependencies besides the peer @deepseek-ai/cordis that DSH already provides. The core (office-service, governance) is bundled inline.


Why this exists (the 10-second pitch)

Long-running agent systems degrade in three ways — this plugin targets exactly those:

DegradationWhat COL does
Executor churn — swap model/agent and lose stateOffices persist by design; swapping an executor never touches Office state (verified loss = 0 in live tests)
Office rot — stale/misleading docs poison later runsExecutors only ever see a compiled, need-to-know Brief, and only after permission pruning — what they can't be trusted with never reaches them
No accountability — who did what, under which constraint?Every mutation is an append-only event with attribution; lifecycle changes require governance, never the executor itself

Honest boundary (same tone as our verdict): this guarantees continuity, hygiene and auditability — not output quality. Quality is bounded by verifier × model, and no architecture removes that. On short one-shot tasks the layer is pure overhead by design; use it where org-scale properties matter.


中文摘要

COL(Context Organization Layer)现在可以作为 DSH 插件安装。 Office = 持久职位(组织槽位);Agent/Model = 可换员工(换人不丢状态);写回必须过验证(asserted/inferred/verified 三分层);一切动作 append-only 可审计。装上后 agent 生态获得:①换执行器状态零丢失(live 实测);②上下文污染免疫(编译选择而非提示运气);③全动作可追溯。本包自包含(核心内联),仅依赖 DSH 已提供的 @deepseek-ai/cordis。当前边界与限制见 README 后文。


**Behaviour change: a child Office's budget is now paid for by its parent, inside the very operation that creates it, and the amount is a policy slice unless a principal allocates one explicitly. Before this, a child could be created holding an unaccounted default (1,000,000) while its parent was never charged — which made R11, "the physical constraint on span", false on the model-facing path. See Boundaries & FAQ for what is still open.

Relationship to the upstream implementation (col-core)

This plugin is one binding of COL to DSH/Cordis, and it carries an inlined implementation: installing it never depends on another package being fetchable from a registry (zero runtime dependencies).

There is also a framework-agnostic upstream implementation, col-core (also Apache-2.0), kept in its own repository. The two are same-origin and diverged in both directions — the honest description is not "this plugin is a thin binding of the core". What they genuinely share is written down and enforced:

  • contract/CONTRACT.md — the shared semantics (C1–C11), plus an explicit list of what is not shared and where the two implementations still differ. Nothing goes in unless both sides already satisfy it.
  • contract/vectors.json + contract/runner.mjs — one executable case per invariant. The same file runs in both repositories; npm test runs this repo's side and additionally cross-checks that the vendored copies are still byte-identical to the upstream ones.
  • Discipline — a shared-semantics change lands in col-core first, then here. If either side stops satisfying an invariant, the vectors go red: divergence becomes visible instead of being discovered later. Where the two implementations genuinely differ today they are pinned, not papered over — side-specific expectations in one vector, listed in CONTRACT.md §3.
  • Direction of travel, with evidence. Writing the vectors surfaced three cases where a shared invariant was really only true here (refusals left no trace upstream, create bypassed the guard upstream, hard constraints were inexpressible upstream). All three were back-ported into col-core rather than documented away, so CONTRACT.md grew an invariant (C11) and §3 shrank to a single registered difference: whether an executor may derive after declaring an intent source (X3 — upstream says no, this plugin says yes when intentBy is given).

Quickstart

1. Install

Requires an installed DSH (dsh CLI) and Node ^22.19.0 || >=24.0.0 (see engines in package.json).

# install straight from the repository (no registry publish required):
dsh plugin --profile headless add github:laodonge/col-dsh-plugin
# or: dsh plugin --profile web add github:laodonge/col-dsh-plugin
# or from a local checkout: dsh plugin --profile headless add /path/to/col-dsh-plugin

That is the whole install. The package declares a profile layer (dsh.bundle.patch → cordis.patch.yml), so dsh plugin add mounts two rows for you:

RowWhat it provides
colthe service itself — ctx.col: Offices, governance, audit, Briefs
col-toolthe model-callable tools col_list_offices / col_get_office / col_create_office

Why this matters: without the dsh.bundle declaration, dsh plugin add installs the package as an ordinary dependency and mounts nothing — the command succeeds and ctx.col still does not exist. (DSH prints a warning in that case: "declares no dsh.bundle — installed as a plain dependency, not a profile layer". test/packaging.test.mjs guards our declaration so it cannot silently regress.)

2. Seed the organization (once)

The tool surface cannot bootstrap itself: col_create_office requires a parentId, and R14 requires the intent to come from inside that parent's governance scope. The first Office therefore has to be created by human:* / governance — that is the design, not a missing feature. Skip this step and every model call just reports an empty organization (total: 0) while looking perfectly healthy.

# from the profile directory that owns the plugin (or from a checkout)
node node_modules/col-dsh-plugin/examples/seed-root.mjs

Idempotent (existing Offices are only printed), and it takes --data-dir <dir>; the default is ~/.col/ctx-data — the same dataDir the col row mounts with.

3. Override the defaults (optional)

The layer mounts with sensible defaults (dataDir → ~/.col/ctx-data). To change any row, address it by id from your own overlay:

# e.g. $DSH_HOME/profiles/headless/cordis.patch.yml
- id: col
  config:
    dataDir: '/var/lib/col'
    sandbox:
      allowed: ['fs:write-workspace']   # R7: binding.effect must be a subset of this

4. Verify it loaded

The service registers as ctx.col. Authority is explicit: every write takes an identity as its last argument, and governance acts on behalf of a human principal. A quick round-trip:

// @runnable — this block is executed by test/readme-examples.test.mjs, so it cannot drift.
// `ctx` is your plugin context; `ctx.col` is this service.
const root = await ctx.col.create(
  { kind: 'global', role: 'root', goal: 'ship the thing', goalHash: 'g1' },
  'human:you',                          // ← goal is exogenous: only human:* may set it (R9)
)
await ctx.col.activate(root, 'governance')     // lifecycle is a governance act (R3)
const domain = await ctx.col.create(
  { kind: 'domain', role: 'backend', parent: { id: root.id, stateRev: root.stateRev } },
  'governance',
)
await ctx.col.activate(domain, 'governance')
console.log(await ctx.col.getLatest(domain.id))   // durable, stateRev-tracked org slot

Executors (models, scripts) get the least privilege by default and must declare where their intent came from — a Office is an organizational slot, so minting one is not something an executor may do on its own initiative (R14):

// @runnable
// Executor derives a subordinate position **for the Office it works in**:
const child = await ctx.col.create(
  { kind: 'task', role: 'coder', parent: { id: domain.id, stateRev: domain.stateRev } },
  { actor: 'executor', intentBy: domain.id, authorizedBy: domain.id },
)
// Compile what that executor is allowed to see, then hand it over:
const brief = await ctx.col.brief(child.id, 'executor', { role: 'coder' })
console.log(brief.cons.hard, brief.kn.map((k) => k.claim))

Example: give the model the tool

examples/col-tool.mjs (drop into your overlay as a plugin, inject: ['tools', 'col']):

ctx.tools.register(defineTool({
  name: 'col_create_office',
  description: 'Propose a new task slot (a persistent Office) under the parent you act for.',
  parameters: {
    role: { type: 'string', required: true },
    parentId: { type: 'string', required: true, description: 'the Office you act for' },
  },
  output: {
    schema: { type: 'object', additionalProperties: true },
    render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }],
  },
  // Goes through `derive`, not a bare `create` — that is what carries the accounting.
  async execute(args) {
    const parent = await ctx.col.getLatest(args.parentId)
    const { child } = await ctx.col.derive(parent.id, {
      role: args.role, actor: 'executor', intentBy: parent.id, authorizedBy: parent.id,
    })
    return child
  },
}))

A model may derive a slot, but never decides how much it gets. A child's budget is always paid for by its parent: create — and therefore derive — charges the parent's committed inside the same operation, and the amount is a policy slice (a fixed share of the parent's remaining budget) unless a principal explicitly allocates one. That is why the tool routes through derive. A live run had shown the model-facing path minting slots holding 10× their parent's budget while the parent's committed stayed empty — R11, the "physical constraint on span", bypassed end to end. An executor's only choice is whether to derive, never how much: col.create(..., { budgetTokens }) from a non-principal is refused. See Boundaries & FAQ for what is still missing.


Service API (ctx.col)

MethodSemantics
create({ kind, role, ... }) → {id, stateRev, snapRev, parent, budget}create a Office (proposed); when it has a parent, its budget is charged to that parent in the same operation and budget.source says where the amount came from
activate(ref)proposed → active (stateRev+1, snapRev+1)
commit(ref, beliefs)3-tier write-back → state accepts verified only; kn accepts inferred/verified; asserted never persists (stateRev+1, snapRev+1, supersede-folded history)
assign(ref, binding)swap executor — event + exec update; stateRev+1 (serialized per Office, so a racing assign on the same ref is refused, not lost), snapRev unchanged (no structural snapshot)
get(ref) / getLatest(id) / events(id)optimistic read / unconditional latest read / audit event stream
view(id, actor)permission-pruned Office for that identity (TCB)
brief(id, actor, opts)View → compiled Brief for that executor (see below)
derive(parentId, opts)create a child task Office and return its seed Brief; the grant is the policy slice unless a principal passes budgetTokens
chargeBudget(ref, {tokens, charge})budget accounting — 'commit' reserves for children, 'consume' records actual spend
interact(ref, {direction, to, message})gate + record an interaction per the tree rule; delivery is the adapter's job
transition(ref, op, reason, actor)suspend / resume / terminate / archive / quarantine / release (governance semantics)

Three separate version numbers — so concurrency protection is not traded away for snapshot economy:

NameBumps whenPurpose
stateRevevery state change (including assign)optimistic concurrency (CAS)
snapRevonly when a structural snapshot is producedsnapshot addressing
seqevery eventaudit ordering

Preconditions are applied uniformly for every mutating op (NOT_FOUND → REV_MISMATCH → STATE), and every mutating op runs inside a per-Office critical section: with two writers holding refs that are equally fresh, the second one's stateRev has moved by the time it writes, so it is refused rather than silently overwriting. (That last part is why the sentence is not merely "preconditions are checked": measured before the fix, six concurrent commits with the same ref all reported success while only one belief landed — a receipt that lies is worse than an error. The critical section is per process; sharing one dataDir across processes is outside the single-writer assumption, see Boundaries & FAQ.). The default guard is fail-closed: with no policy configured, operations are refused — "unconfigured" never means "full authority". Rejected operations still leave an audit event.

Persistence: dataDir/ctx/<id>/ → current.json + append-only events.jsonl + per-snapshot s<snapRev>.json (atomic writes). Restart-safe (verified in two environments).

Storage & journal (the durability layer)

IO is not in the service — it lives behind two small seams, so the semantics are provably independent of where state is kept:

LayerResponsibilitySwappable
store.jsload / save / append / snapshot / list — IO only. No state machine, no beliefs, no permissions✅ file · memory — the entire test suite runs against both, which is the evidence that semantics are decoupled from storage
journal.jsseq allocation · two-phase events · replay✅ (any store)

Atomic writes go fsync(tmp) → rename → fsync(dir). That buys power-loss consistency, not merely process-crash consistency — and the store reports which level it actually achieved (durability), rather than assuming.

Two-phase events fix an audit defect: previously an event was written before the state and never rolled back on failure, so the log could record changes that never happened. Now every operation writes an intent (what was asked, plus the decision), then applies and persists, then an outcome (committed / aborted). Consequences:

  • A refused operation leaves only an intent — attributable, with no false "it happened" record.
  • A crash between the two is detectable: replay() reports it as a pending transaction, along with the pre-change stateRev needed to tell whether the change took effect.
  • replay() reports, it does not decide: rolling back or redoing is the orchestrator's call. (Rollback capability does not confer semantic authority.)

Sequence numbers are allocated from an in-memory counter, rebuilt once on startup from the existing stream. This removes both the O(n) full-file read per append and the read-then-append race — there is a concurrency test that fires 20 simultaneous appends and asserts unique, contiguous numbers.

Why seq matters more than it looks. Under distribution, machine clocks disagree, so ts cannot be trusted for ordering — the logical sequence becomes correctness, not just auditability. That is also why the single-writer constraint has to become explicit (a write token, or memory held by one owner) and why the WAL + replay is the disaster-recovery basis: memory becomes a single point, and replay is how it comes back.


Identity & authority (who may do what)

Every write method takes an identity as its last parameter. The default is the least privilege ('executor') — never maximum authority.

IdentityMay do
executor (default; models, scripts, tools)create — a new Office lands in proposed, i.e. a proposal nobody can write into yet
❌ lifecycle (activate / suspend / resume / terminate / archive / quarantine / release) · ❌ commit (writing beliefs) · ❌ assign (rebinding an executor)
governanceeverything
human:<id>everything
anything elserefused (fail-closed)

Omitting the identity is a BAD_REQUEST — not a silent elevation. "Unconfigured" never means "full authority".

Honest boundary: within a single process this identity is a caller-supplied string, so it is not a security boundary — any in-process code can claim 'governance'. What this design buys is that the default is least privilege, that the authority checks are actually reachable (and therefore testable) on the product surface, and that the seam exists for a real identity derivation later. The enforcement boundary itself is the sandbox / out-of-process deployment — which this package does not provide.

Before this was fixed, the adapter hardcoded the actor to 'governance', which made the core's executor checks permanently unreachable: npm test could be fully green while the shipped path had no enforcement at all. test/adapter-path.test.mjs now asserts the adapter surface directly, and checks that the adapter contains no fabricated identity and no COL semantics.

The authority checker

A single decision point — check(Request) → Decision. Rules are declarative and attributable: every decision carries the ruleId it came from, and describe() lists them all with their rationale.

Fail-closed is structural, not aspirational:

SituationResult
no rules configuredNO_POLICY
unknown actionUNKNOWN_ACTION
a rule throws (in when or check)RULE_ERROR, carrying that rule's id
malformed request (missing actor/action)BAD_REQUEST
unknown identity classDENIED
any rule deniesdeny-overrides — later rules are not even evaluated

Decisions are three-state, not two: allow, deny, and APPROVAL_REQUIRED (with by: ['human:principal']). The third state is what makes "a human is in the loop" a structure rather than a process convention — without it, "requires approval" has nowhere to live.

RuleChecks
R1bidentity class must be known
R2executor cannot commit beliefs (self-write ban)
R3executor cannot perform lifecycle operations
R3bper-identity operation scope (executor → ['create']; verifier → writes nothing)
R4writing verified requires evidence, and evidence.producedBy !== actor
R5asserted never persists; state takes only verified; kn takes inferred/verified
R6capability ceiling: prohibited → deny, ask → approval required, outside allow → deny
R7effect boundary: binding.effect ⊆ sandbox.allowed
R8assign binding must be a valid ExecutorContract (null = explicit unbind)
R9goal is exogenous — only human:* may change it; goalHash must not change
R10no self-replication: unauthorized actors cannot provision execution environments
R11budget ceiling: committed + consumed + requested ≤ tokens
R12the constitution is immutable at runtime
R13tree-only: interaction direction ∈ {self, parent, child}
R14intent provenance — intentBy / authorizedBy must be the target itself or one of its ancestors
R15measure integrity — changing the measure requires human approval; measureHash must not change
R16group containment — a group's members' capability union must not exceed its authorization

R14 in one paragraph (why it exists). Consider A (a sibling of C) and B (a child of C). A and B collude: B gets C to do X — and X was already within C's authority. Every edge is legal (A→B is legal, B→C is a parent-child edge), the victim C need not be complicit, and no new permission is ever created — the puppet hijacks intent, not authority. So every check of the form "who is executing?" is blind to it. The defence is to record and validate where the intent came from: C must declare intentBy = A, and A is not in C's ancestor chain ⇒ refused.

// A legitimate delegation: the executor acts for the Office it works in.
await ctx.col.create(
  { kind: 'task', role: 'coder', parent: { id: parent.id, stateRev: parent.stateRev } },
  { actor: 'executor', intentBy: parent.id, authorizedBy: parent.id },
)
// Executors get NO default intent. Governance and human:<id> are treated as
// intent sources themselves, so they may omit it.

Honest boundary of R14. authorizedBy is a credential slot, not a signature. What is deterministically checked is: both fields are present, both fall inside the target's governance scope, and a least-privilege actor cannot authorize itself. Under the tree-only decision this is verified by two-way record reconciliation, and no cryptographic signing is required at this layer — so this is structural and reconcilable, not cryptographic proof. (Request.env.chain is computed by the engine and must never be taken from an untrusted caller — otherwise the checked object supplies its own answer.)

Honest boundaries of the checker

  • R4 does not verify that a claim is true. It verifies that evidence exists and comes from another party. Content truthfulness is the Verifier's job — conflating the two would be overclaiming.
  • R7 requires the host to declare a sandbox. With none declared it refuses rather than assuming an empty boundary.
  • R10 / R12 are reachable through create (they fire when the payload carries dataDir / a new constitution). R11 and R13 are reachable through chargeBudget and interact.
  • R14's authorizedBy is not a signature (see above).
  • Every rule is now reachable through the product surface. test/rule-reachability.test.mjs enforces this in both directions: each rule must be triggerable through the API (and the audit event must carry the right ruleId), and each rule must see its own inputs — the "must-allow" half matters because a rule that lost its input still denies, so a deny-only test cannot tell the difference. Any rule that cannot be reached must be listed in an explicit exemption table with a written reason; that table is currently empty.

Configuration for effect boundaries:

- id: col
  name: 'col-dsh-plugin'
  config:
    dataDir: '/var/lib/col'
    sandbox:
      allowed: ['fs:write-workspace', 'net:none']   # R7: binding.effect must be a subset of this

Minimal closed loop (run it)

The acceptance criterion is not a diagram — it is a runnable multi-Office flow with a fake executor, no LLM, and no harness whatsoever:

node examples/minimal-loop.mjs
 1. root 设立并启用:goalHash=g-1
 2. 两个域设立(各自带硬约束)
 3. 两个域各绑定一个非 LLM 执行器(echo / counter)
 4. 派生任务 Office(父指针 = backend@2)
 5. Brief 下发:硬约束=["禁止访问网络"] 预算=1250
 6. 执行器产出:claims=["echo:ran"] producedBy=fake/echo
 7. verified 写回成功:stateRev=2
 8. 兄弟分支请求同一 claim → 未泄漏(隔离成立)
 9. 重启后状态仍在,且 confidence=verified

It exercises the whole chain: exogenous goal → domains → executor binding → derive → Brief → run → deterministic verification → verified write-back → isolation → persistence.

The executor is pluggable, and here is the evidence. A non-LLM reference executor ships in src/internal/executor.js (echo, counter), because without a second implementation, "execution capability is pluggable" has no evidence. Executors are contractual black boxes with a four-tuple boundary — driver (what Brief it accepts), capability (what it may do), effect (what external state it may touch), `report