Skip to content

CommandSuite v0.3.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 23:10
da77204

csuite-cli

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Fix objectives_list returning only assigned work to any caller holding objectives.create.

    The tool describes itself as listing "objectives you have a relationship with — assigned to you, originated by you, or objectives you're watching," and the agent briefing tells agents to call it after a restart or context compaction rather than trusting memory. For a member who originates or watches without being assigned — the coordinating role — it returned an empty plate.

    The defect was not where it looked. The route already implemented the relationship union, and a member without objectives.create got exactly what the description promised. handleObjectivesList unconditionally sent assignee: <self>, which the route honours for privileged callers, bypassing the union entirely. So the permission granting more authority was what removed the capability, and the role that most needs to see what it originated and watches was the only one that could not.

    ListObjectivesQuery gains related, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction as assignee for members lacking objectives.create. The MCP tool now sends related instead of assignee.

    The union is deliberately not the default for a privileged caller with no filter: the director dashboard (web-ui/src/lib/objectives.ts) calls listObjectives() bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies on assignee staying narrow — folding watched objectives into it would change what every agent is re-briefed with after compaction.

    The regression fixture is a privileged caller assigned nothing, against a team-wide total larger than their related set. Both properties matter: a plain-member fixture passes against the bug because the union already covers that path, and an equal-sized team total passes against a route that ignores related entirely.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Show recently reported working and blocked activity in the agent roster without presenting it as executor liveness. The broker now supplies the activity window it actually applied, so version-skewed clients do not reconstruct a server-owned value.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Report peak activity-uploader queue occupancy in events and serialized UTF-8 bytes in
    session_end.capture, while continuing to accept older run summaries that omit the new fields.

  • #51 ec161f2 Thanks @keencaliper! - Fix the roster MCP tool rendering every teammate's role as [object Object].

    A role has been an object ({title, description}) since the initial public release, but the agent-facing roster tool interpolated it straight into its output line, so every agent that ever called roster got - Lea [[object Object]] [admin] connected=1 — the one field that says what a teammate does was unreadable, on the one surface an agent has for deciding who to route work to. Every human-facing surface (csuite roster, csuite member list, and the web UI) already read role.title; only the agent's view was broken.

    The tool now renders role.title, matching csuite roster. The existing bridge test asserted only that teammate names appeared in the output, which is why this shipped and survived — it now asserts the role titles render and that [object Object] never appears.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

  • #51 ec161f2 Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through its exports map into dist/, and nothing in module resolution checks that dist/ was built from the src/ on disk — so a stale build produced a green suite that proved nothing. Turbo's dependsOn: ["^build"] covered the root pnpm test and nothing else; a filtered pnpm --filter <pkg> exec vitest run bypassed it entirely.

  • #51 ec161f2 Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.

    Security-relevant. core/src/trace/redact.ts opened by describing "decrypted Anthropic API traffic" — MITM-proxy-era language in the redaction module, when there is no interception, no TLS decryption, and no CA on disk. Reading the code underneath the stale comment turned up a second thing: redactHeaders is a published export of csuite-core with no in-tree caller, and nothing in the repo captures raw HTTP headers any more. The header now states the real threat model — secrets surfacing inside content rather than in intercepted headers — records that redactHeaders is retained as public API rather than because csuite inspects headers, and states what redaction does not cover: the raw-body store keeps bytes verbatim by design, which is what makes byte-exact reconstruction possible.

    dual-authtri-auth on 18 lines across 5 source files, not the 5 sites originally reported: eleven in server/src/app.ts, four in sdk/src/protocol.ts, one each in core/src/session-store.ts, server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — and server/src/auth.ts already called itself "Tri-auth middleware", which is what made the rest identifiable as stale rather than as a naming choice.

    Three of those eleven app.ts lines were missed by the first pass and found in verification: the defining paragraph at the top of the file still read Dual-auth = either bearer or cookie directly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources, GET /secrets) still said Dual-auth. The first pass searched for the term where it expected the term to be; it did not re-run the search afterwards.

    The describe('dual auth (bearer OR cookie)') block in auth.test.ts was deliberately not renamed: it covers exactly those two planes and JWT has its own block, so renaming it would have made the test overclaim.

    activity-uploader.ts said the queue cap was 1 MiB where the constant is 64 MB — stale by 64× in the file that owns the number. It now names both constants and records that drops are counted and surface in session_end.capture.

    sdk/src/types.ts justified hook-sourced user_prompt capture by saying the OTEL request body "truncates large (~60KB+) prompts". The runner uses file mode, which writes complete bodies, so that truncation no longer applies; the rationale is restated accurately.

    MCP tool descriptions now declare the limits their schemas enforce. objectives_complete's result is capped at 4096 characters and said nothing — you discovered it by having a completion rejected after writing it. A tool description is the only specification an agent has for a tool whose implementation it cannot read.

    Declared across every tool backed by a schema with an enforced bound: objectives_create title (200), outcome (2048), body (4096), watchers (64), attachments (64); objectives_discuss body (16384), attachments (64); objectives_update blockReason (2048); objectives_complete result (4096); objectives_watchers add (64) and remove (64); and broadcast, send, and channels_post body (65536) and attachments (64).

    The first pass declared only the limits already known from the objective's scope, and so missed the attachment caps in the very schemas it was editing. The list above was rebuilt the other way round — enumerating every constrained field on each request schema in sdk/src/schemas.ts, then checking each against its tool description — which is what turned up the eight beyond the original set.

    Not addressed, and named because the same enumeration found them: DiscussObjectiveRequestSchema and PushPayloadSchema both accept an optional title that no MCP tool exposes, so an agent cannot set one and a human on the web UI can. That is a surface-parity gap rather than a stale comment, so it is filed rather than fixed here.

    Comments naming symbols that do not exist — found by a second, artifact-bounded method. The passes above were bounded by terms chosen in advance, which cannot find drift nobody suspected. So a second pass enumerated its candidate set from the comments themselves: every backticked identifier appearing in a comment across 240 source files (1,075 distinct), each checked against all non-comment source. 44 were absent; 8 were real. The other 36 are external vocabulary — protocol field names (grant_type, message_stop), env vars, TS compiler options, browser and upstream-SDK symbols.

    file cited actual
    sdk/src/types.ts pollUrl on the /enroll response no such field — the response carries relative verificationUri/verificationUriComplete and no poll hint
    core/src/trace/sse.ts buildAnthropicEntry in anthropic.ts anthropicToGenAi in genai.ts
    core/src/trace/transcript.ts parseMessages in the HTTP path anthropicToGenAi
    server/src/members.ts (×2) TeamConfigSchema ServerConfigSchema
    server/src/run.ts loadTeamConfigFromFile loadServerConfigFromFile
    server/src/team-store.ts seedTeam setTeam
    server/src/files/filesystem-store.ts srcPath src
    web-ui/src/lib/client.ts the __resetForTests convention __reset<Module>ForTests

    The sdk/src/types.ts one is the worst of these: it is a published type documenting a wire field that has never existed, so an SDK consumer reading the doc comment would look for a response field the broker does not send.

    core/src/trace/sse.ts also gained the same disclosure redactHeaders carries: reassembleAnthropicSse, parseSseEvents and looksLikeSseStream are published exports with no in-tree caller — nothing in csuite holds a raw SSE body since capture became transcript- and OTEL-sourced. That is now stated in the header so the module's presence is not read as a live streaming-capture path.

    What remains unswept, stated rather than implied. The backticked-identifier method sees only claims that name a symbol. It cannot see a comment that describes a mechanism in prose without naming anything — the dual-auth and 1 MiB defects were both of that kind and both needed the vocabulary pass to find. Neither method reaches a comment that is wrong about behaviour while naming only symbols that still exist; that residue is unmeasured, and the four new redactJson contract tests are the pattern for closing it where it matters.

  • #51 ec161f2 Thanks @keencaliper! - Count serialized activity-event sizes in UTF-8 bytes rather than UTF-16 code units. The corrected
    counter drives the queued-payload budget, size-triggered flushing, and approximate POST batch
    selection; request-envelope overhead remains outside the POST estimate.

    ASCII behavior is unchanged. The effective limits are tighter for non-ASCII content, especially
    CJK- and emoji-heavy traces. Oldest-event drops at the corrected queued-payload budget remain
    counted and reported in session_end.capture.

csuite-server

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Fix objectives_list returning only assigned work to any caller holding objectives.create.

    The tool describes itself as listing "objectives you have a relationship with — assigned to you, originated by you, or objectives you're watching," and the agent briefing tells agents to call it after a restart or context compaction rather than trusting memory. For a member who originates or watches without being assigned — the coordinating role — it returned an empty plate.

    The defect was not where it looked. The route already implemented the relationship union, and a member without objectives.create got exactly what the description promised. handleObjectivesList unconditionally sent assignee: <self>, which the route honours for privileged callers, bypassing the union entirely. So the permission granting more authority was what removed the capability, and the role that most needs to see what it originated and watches was the only one that could not.

    ListObjectivesQuery gains related, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction as assignee for members lacking objectives.create. The MCP tool now sends related instead of assignee.

    The union is deliberately not the default for a privileged caller with no filter: the director dashboard (web-ui/src/lib/objectives.ts) calls listObjectives() bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies on assignee staying narrow — folding watched objectives into it would change what every agent is re-briefed with after compaction.

    The regression fixture is a privileged caller assigned nothing, against a team-wide total larger than their related set. Both properties matter: a plain-member fixture passes against the bug because the union already covers that path, and an equal-sized team total passes against a route that ignores related entirely.

  • #51 ec161f2 Thanks @keencaliper! - Reassigning an objective no longer strips the previous assignee from its discussion thread.

    Thread membership is computed, not stored — objectiveThreadMembers derives it from the current assignee, the originator, the explicit watchers, and every admin. So reassignment never revoked anything: the assignee term simply stopped matching, and the outgoing assignee fell out of their own objective's thread at exactly the moment they needed to hand over. Posting a handover returned 403: user '<name>' is not a member of objective <id>'s thread, which pushes the most valuable context of the whole transition into a private DM instead of the objective's append-only record.

    reassign now promotes the outgoing assignee to watcher in the same transaction, emitting a watcher_added event carrying reason: 'reassigned-from' so the audit log explains why they are on the list.

    Why a watcher rather than "retaining them as a thread member": there is no membership record to retain. Membership is a union of derived grants, and watchers is the only durable one the model can already express — it persists, it grants posting rights, and it receives future pushes. The alternative would have required a new concept.

    The grant is deliberately visible: a former assignee appears in the watcher list, which is true — they worked on it — and an accountable watcher beats an invisible one. The accepted cost is that repeated reassignment grows the list.

    No-ops are skipped: the outgoing assignee is not promoted when they are the originator (membership already derives from that) or already a watcher, so no duplicate entries.

    Confirmed by test rather than assumed: a former assignee who was independently a watcher retained access before this change, which is what establishes that nothing was being revoked. Reassignment also strips neither the incoming assignee, the originator, nor pre-existing watchers.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Show recently reported working and blocked activity in the agent roster without presenting it as executor liveness. The broker now supplies the activity window it actually applied, so version-skewed clients do not reconstruct a server-owned value.

  • #51 ec161f2 Thanks @keencaliper! - Make activity and GenAI range reads losslessly pageable with composite timestamp/id cursors.

    TracePanel now traverses every page before joining activity with GenAI records, member activity pagination preserves rows sharing a timestamp, and GenAI enrichment failures are surfaced while retaining the marker-only fallback. Consumer-side history caps that silently discarded rows have been removed.

  • #51 ec161f2 Thanks @keencaliper! - FsEntry now carries canWrite — the server's own canWrite() answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner is obj:<id> and the rule includes objective membership, which a client cannot determine for an arbitrary path. The field is optional, so an older server that omits it still parses. The web UI's Delete control now asks rather than inferring, which restores it for objective members who were entitled to it all along.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

  • #51 ec161f2 Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through its exports map into dist/, and nothing in module resolution checks that dist/ was built from the src/ on disk — so a stale build produced a green suite that proved nothing. Turbo's dependsOn: ["^build"] covered the root pnpm test and nothing else; a filtered pnpm --filter <pkg> exec vitest run bypassed it entirely.

  • #51 ec161f2 Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.

    Security-relevant. core/src/trace/redact.ts opened by describing "decrypted Anthropic API traffic" — MITM-proxy-era language in the redaction module, when there is no interception, no TLS decryption, and no CA on disk. Reading the code underneath the stale comment turned up a second thing: redactHeaders is a published export of csuite-core with no in-tree caller, and nothing in the repo captures raw HTTP headers any more. The header now states the real threat model — secrets surfacing inside content rather than in intercepted headers — records that redactHeaders is retained as public API rather than because csuite inspects headers, and states what redaction does not cover: the raw-body store keeps bytes verbatim by design, which is what makes byte-exact reconstruction possible.

    dual-authtri-auth on 18 lines across 5 source files, not the 5 sites originally reported: eleven in server/src/app.ts, four in sdk/src/protocol.ts, one each in core/src/session-store.ts, server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — and server/src/auth.ts already called itself "Tri-auth middleware", which is what made the rest identifiable as stale rather than as a naming choice.

    Three of those eleven app.ts lines were missed by the first pass and found in verification: the defining paragraph at the top of the file still read Dual-auth = either bearer or cookie directly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources, GET /secrets) still said Dual-auth. The first pass searched for the term where it expected the term to be; it did not re-run the search afterwards.

    The describe('dual auth (bearer OR cookie)') block in auth.test.ts was deliberately not renamed: it covers exactly those two planes and JWT has its own block, so renaming it would have made the test overclaim.

    activity-uploader.ts said the queue cap was 1 MiB where the constant is 64 MB — stale by 64× in the file that owns the number. It now names both constants and records that drops are counted and surface in session_end.capture.

    sdk/src/types.ts justified hook-sourced user_prompt capture by saying the OTEL request body "truncates large (~60KB+) prompts". The runner uses file mode, which writes complete bodies, so that truncation no longer applies; the rationale is restated accurately.

    MCP tool descriptions now declare the limits their schemas enforce. objectives_complete's result is capped at 4096 characters and said nothing — you discovered it by having a completion rejected after writing it. A tool description is the only specification an agent has for a tool whose implementation it cannot read.

    Declared across every tool backed by a schema with an enforced bound: objectives_create title (200), outcome (2048), body (4096), watchers (64), attachments (64); objectives_discuss body (16384), attachments (64); objectives_update blockReason (2048); objectives_complete result (4096); objectives_watchers add (64) and remove (64); and broadcast, send, and channels_post body (65536) and attachments (64).

    The first pass declared only the limits already known from the objective's scope, and so missed the attachment caps in the very schemas it was editing. The list above was rebuilt the other way round — enumerating every constrained field on each request schema in sdk/src/schemas.ts, then checking each against its tool description — which is what turned up the eight beyond the original set.

    Not addressed, and named because the same enumeration found them: DiscussObjectiveRequestSchema and PushPayloadSchema both accept an optional title that no MCP tool exposes, so an agent cannot set one and a human on the web UI can. That is a surface-parity gap rather than a stale comment, so it is filed rather than fixed here.

    Comments naming symbols that do not exist — found by a second, artifact-bounded method. The passes above were bounded by terms chosen in advance, which cannot find drift nobody suspected. So a second pass enumerated its candidate set from the comments themselves: every backticked identifier appearing in a comment across 240 source files (1,075 distinct), each checked against all non-comment source. 44 were absent; 8 were real. The other 36 are external vocabulary — protocol field names (grant_type, message_stop), env vars, TS compiler options, browser and upstream-SDK symbols.

    file cited actual
    sdk/src/types.ts pollUrl on the /enroll response no such field — the response carries relative verificationUri/verificationUriComplete and no poll hint
    core/src/trace/sse.ts buildAnthropicEntry in anthropic.ts anthropicToGenAi in genai.ts
    core/src/trace/transcript.ts parseMessages in the HTTP path anthropicToGenAi
    server/src/members.ts (×2) TeamConfigSchema ServerConfigSchema
    server/src/run.ts loadTeamConfigFromFile loadServerConfigFromFile
    server/src/team-store.ts seedTeam setTeam
    server/src/files/filesystem-store.ts srcPath src
    web-ui/src/lib/client.ts the __resetForTests convention __reset<Module>ForTests

    The sdk/src/types.ts one is the worst of these: it is a published type documenting a wire field that has never existed, so an SDK consumer reading the doc comment would look for a response field the broker does not send.

    core/src/trace/sse.ts also gained the same disclosure redactHeaders carries: reassembleAnthropicSse, parseSseEvents and looksLikeSseStream are published exports with no in-tree caller — nothing in csuite holds a raw SSE body since capture became transcript- and OTEL-sourced. That is now stated in the header so the module's presence is not read as a live streaming-capture path.

    What remains unswept, stated rather than implied. The backticked-identifier method sees only claims that name a symbol. It cannot see a comment that describes a mechanism in prose without naming anything — the dual-auth and 1 MiB defects were both of that kind and both needed the vocabulary pass to find. Neither method reaches a comment that is wrong about behaviour while naming only symbols that still exist; that residue is unmeasured, and the four new redactJson contract tests are the pattern for closing it where it matters.

csuite-core

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Make activity and GenAI range reads losslessly pageable with composite timestamp/id cursors.

    TracePanel now traverses every page before joining activity with GenAI records, member activity pagination preserves rows sharing a timestamp, and GenAI enrichment failures are surfaced while retaining the marker-only fallback. Consumer-side history caps that silently discarded rows have been removed.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

  • #51 ec161f2 Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through its exports map into dist/, and nothing in module resolution checks that dist/ was built from the src/ on disk — so a stale build produced a green suite that proved nothing. Turbo's dependsOn: ["^build"] covered the root pnpm test and nothing else; a filtered pnpm --filter <pkg> exec vitest run bypassed it entirely.

  • #51 ec161f2 Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.

    Security-relevant. core/src/trace/redact.ts opened by describing "decrypted Anthropic API traffic" — MITM-proxy-era language in the redaction module, when there is no interception, no TLS decryption, and no CA on disk. Reading the code underneath the stale comment turned up a second thing: redactHeaders is a published export of csuite-core with no in-tree caller, and nothing in the repo captures raw HTTP headers any more. The header now states the real threat model — secrets surfacing inside content rather than in intercepted headers — records that redactHeaders is retained as public API rather than because csuite inspects headers, and states what redaction does not cover: the raw-body store keeps bytes verbatim by design, which is what makes byte-exact reconstruction possible.

    dual-authtri-auth on 18 lines across 5 source files, not the 5 sites originally reported: eleven in server/src/app.ts, four in sdk/src/protocol.ts, one each in core/src/session-store.ts, server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — and server/src/auth.ts already called itself "Tri-auth middleware", which is what made the rest identifiable as stale rather than as a naming choice.

    Three of those eleven app.ts lines were missed by the first pass and found in verification: the defining paragraph at the top of the file still read Dual-auth = either bearer or cookie directly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources, GET /secrets) still said Dual-auth. The first pass searched for the term where it expected the term to be; it did not re-run the search afterwards.

    The describe('dual auth (bearer OR cookie)') block in auth.test.ts was deliberately not renamed: it covers exactly those two planes and JWT has its own block, so renaming it would have made the test overclaim.

    activity-uploader.ts said the queue cap was 1 MiB where the constant is 64 MB — stale by 64× in the file that owns the number. It now names both constants and records that drops are counted and surface in session_end.capture.

    sdk/src/types.ts justified hook-sourced user_prompt capture by saying the OTEL request body "truncates large (~60KB+) prompts". The runner uses file mode, which writes complete bodies, so that truncation no longer applies; the rationale is restated accurately.

    MCP tool descriptions now declare the limits their schemas enforce. objectives_complete's result is capped at 4096 characters and said nothing — you discovered it by having a completion rejected after writing it. A tool description is the only specification an agent has for a tool whose implementation it cannot read.

    Declared across every tool backed by a schema with an enforced bound: objectives_create title (200), outcome (2048), body (4096), watchers (64), attachments (64); objectives_discuss body (16384), attachments (64); objectives_update blockReason (2048); objectives_complete result (4096); objectives_watchers add (64) and remove (64); and broadcast, send, and channels_post body (65536) and attachments (64).

    The first pass declared only the limits already known from the objective's scope, and so missed the attachment caps in the very schemas it was editing. The list above was rebuilt the other way round — enumerating every constrained field on each request schema in sdk/src/schemas.ts, then checking each against its tool description — which is what turned up the eight beyond the original set.

    Not addressed, and named because the same enumeration found them: DiscussObjectiveRequestSchema and PushPayloadSchema both accept an optional title that no MCP tool exposes, so an agent cannot set one and a human on the web UI can. That is a surface-parity gap rather than a stale comment, so it is filed rather than fixed here.

    Comments naming symbols that do not exist — found by a second, artifact-bounded method. The passes above were bounded by terms chosen in advance, which cannot find drift nobody suspected. So a second pass enumerated its candidate set from the comments themselves: every backticked identifier appearing in a comment across 240 source files (1,075 distinct), each checked against all non-comment source. 44 were absent; 8 were real. The other 36 are external vocabulary — protocol field names (grant_type, message_stop), env vars, TS compiler options, browser and upstream-SDK symbols.

    file cited actual
    sdk/src/types.ts pollUrl on the /enroll response no such field — the response carries relative verificationUri/verificationUriComplete and no poll hint
    core/src/trace/sse.ts buildAnthropicEntry in anthropic.ts anthropicToGenAi in genai.ts
    core/src/trace/transcript.ts parseMessages in the HTTP path anthropicToGenAi
    server/src/members.ts (×2) TeamConfigSchema ServerConfigSchema
    server/src/run.ts loadTeamConfigFromFile loadServerConfigFromFile
    server/src/team-store.ts seedTeam setTeam
    server/src/files/filesystem-store.ts srcPath src
    web-ui/src/lib/client.ts the __resetForTests convention __reset<Module>ForTests

    The sdk/src/types.ts one is the worst of these: it is a published type documenting a wire field that has never existed, so an SDK consumer reading the doc comment would look for a response field the broker does not send.

    core/src/trace/sse.ts also gained the same disclosure redactHeaders carries: reassembleAnthropicSse, parseSseEvents and looksLikeSseStream are published exports with no in-tree caller — nothing in csuite holds a raw SSE body since capture became transcript- and OTEL-sourced. That is now stated in the header so the module's presence is not read as a live streaming-capture path.

    What remains unswept, stated rather than implied. The backticked-identifier method sees only claims that name a symbol. It cannot see a comment that describes a mechanism in prose without naming anything — the dual-auth and 1 MiB defects were both of that kind and both needed the vocabulary pass to find. Neither method reaches a comment that is wrong about behaviour while naming only symbols that still exist; that residue is unmeasured, and the four new redactJson contract tests are the pattern for closing it where it matters.

csuite-sdk

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Fix objectives_list returning only assigned work to any caller holding objectives.create.

    The tool describes itself as listing "objectives you have a relationship with — assigned to you, originated by you, or objectives you're watching," and the agent briefing tells agents to call it after a restart or context compaction rather than trusting memory. For a member who originates or watches without being assigned — the coordinating role — it returned an empty plate.

    The defect was not where it looked. The route already implemented the relationship union, and a member without objectives.create got exactly what the description promised. handleObjectivesList unconditionally sent assignee: <self>, which the route honours for privileged callers, bypassing the union entirely. So the permission granting more authority was what removed the capability, and the role that most needs to see what it originated and watches was the only one that could not.

    ListObjectivesQuery gains related, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction as assignee for members lacking objectives.create. The MCP tool now sends related instead of assignee.

    The union is deliberately not the default for a privileged caller with no filter: the director dashboard (web-ui/src/lib/objectives.ts) calls listObjectives() bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies on assignee staying narrow — folding watched objectives into it would change what every agent is re-briefed with after compaction.

    The regression fixture is a privileged caller assigned nothing, against a team-wide total larger than their related set. Both properties matter: a plain-member fixture passes against the bug because the union already covers that path, and an equal-sized team total passes against a route that ignores related entirely.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Show recently reported working and blocked activity in the agent roster without presenting it as executor liveness. The broker now supplies the activity window it actually applied, so version-skewed clients do not reconstruct a server-owned value.

  • #51 ec161f2 Thanks @keencaliper! - Make activity and GenAI range reads losslessly pageable with composite timestamp/id cursors.

    TracePanel now traverses every page before joining activity with GenAI records, member activity pagination preserves rows sharing a timestamp, and GenAI enrichment failures are surfaced while retaining the marker-only fallback. Consumer-side history caps that silently discarded rows have been removed.

  • #51 ec161f2 Thanks @keencaliper! - FsEntry now carries canWrite — the server's own canWrite() answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner is obj:<id> and the rule includes objective membership, which a client cannot determine for an arbitrary path. The field is optional, so an older server that omits it still parses. The web UI's Delete control now asks rather than inferring, which restores it for objective members who were entitled to it all along.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Report peak activity-uploader queue occupancy in events and serialized UTF-8 bytes in
    session_end.capture, while continuing to accept older run summaries that omit the new fields.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

  • #51 ec161f2 Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through its exports map into dist/, and nothing in module resolution checks that dist/ was built from the src/ on disk — so a stale build produced a green suite that proved nothing. Turbo's dependsOn: ["^build"] covered the root pnpm test and nothing else; a filtered pnpm --filter <pkg> exec vitest run bypassed it entirely.

  • #51 ec161f2 Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.

    Security-relevant. core/src/trace/redact.ts opened by describing "decrypted Anthropic API traffic" — MITM-proxy-era language in the redaction module, when there is no interception, no TLS decryption, and no CA on disk. Reading the code underneath the stale comment turned up a second thing: redactHeaders is a published export of csuite-core with no in-tree caller, and nothing in the repo captures raw HTTP headers any more. The header now states the real threat model — secrets surfacing inside content rather than in intercepted headers — records that redactHeaders is retained as public API rather than because csuite inspects headers, and states what redaction does not cover: the raw-body store keeps bytes verbatim by design, which is what makes byte-exact reconstruction possible.

    dual-authtri-auth on 18 lines across 5 source files, not the 5 sites originally reported: eleven in server/src/app.ts, four in sdk/src/protocol.ts, one each in core/src/session-store.ts, server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — and server/src/auth.ts already called itself "Tri-auth middleware", which is what made the rest identifiable as stale rather than as a naming choice.

    Three of those eleven app.ts lines were missed by the first pass and found in verification: the defining paragraph at the top of the file still read Dual-auth = either bearer or cookie directly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources, GET /secrets) still said Dual-auth. The first pass searched for the term where it expected the term to be; it did not re-run the search afterwards.

    The describe('dual auth (bearer OR cookie)') block in auth.test.ts was deliberately not renamed: it covers exactly those two planes and JWT has its own block, so renaming it would have made the test overclaim.

    activity-uploader.ts said the queue cap was 1 MiB where the constant is 64 MB — stale by 64× in the file that owns the number. It now names both constants and records that drops are counted and surface in session_end.capture.

    sdk/src/types.ts justified hook-sourced user_prompt capture by saying the OTEL request body "truncates large (~60KB+) prompts". The runner uses file mode, which writes complete bodies, so that truncation no longer applies; the rationale is restated accurately.

    MCP tool descriptions now declare the limits their schemas enforce. objectives_complete's result is capped at 4096 characters and said nothing — you discovered it by having a completion rejected after writing it. A tool description is the only specification an agent has for a tool whose implementation it cannot read.

    Declared across every tool backed by a schema with an enforced bound: objectives_create title (200), outcome (2048), body (4096), watchers (64), attachments (64); objectives_discuss body (16384), attachments (64); objectives_update blockReason (2048); objectives_complete result (4096); objectives_watchers add (64) and remove (64); and broadcast, send, and channels_post body (65536) and attachments (64).

    The first pass declared only the limits already known from the objective's scope, and so missed the attachment caps in the very schemas it was editing. The list above was rebuilt the other way round — enumerating every constrained field on each request schema in sdk/src/schemas.ts, then checking each against its tool description — which is what turned up the eight beyond the original set.

    Not addressed, and named because the same enumeration found them: DiscussObjectiveRequestSchema and PushPayloadSchema both accept an optional title that no MCP tool exposes, so an agent cannot set one and a human on the web UI can. That is a surface-parity gap rather than a stale comment, so it is filed rather than fixed here.

    Comments naming symbols that do not exist — found by a second, artifact-bounded method. The passes above were bounded by terms chosen in advance, which cannot find drift nobody suspected. So a second pass enumerated its candidate set from the comments themselves: every backticked identifier appearing in a comment across 240 source files (1,075 distinct), each checked against all non-comment source. 44 were absent; 8 were real. The other 36 are external vocabulary — protocol field names (grant_type, message_stop), env vars, TS compiler options, browser and upstream-SDK symbols.

    file cited actual
    sdk/src/types.ts pollUrl on the /enroll response no such field — the response carries relative verificationUri/verificationUriComplete and no poll hint
    core/src/trace/sse.ts buildAnthropicEntry in anthropic.ts anthropicToGenAi in genai.ts
    core/src/trace/transcript.ts parseMessages in the HTTP path anthropicToGenAi
    server/src/members.ts (×2) TeamConfigSchema ServerConfigSchema
    server/src/run.ts loadTeamConfigFromFile loadServerConfigFromFile
    server/src/team-store.ts seedTeam setTeam
    server/src/files/filesystem-store.ts srcPath src
    web-ui/src/lib/client.ts the __resetForTests convention __reset<Module>ForTests

    The sdk/src/types.ts one is the worst of these: it is a published type documenting a wire field that has never existed, so an SDK consumer reading the doc comment would look for a response field the broker does not send.

    core/src/trace/sse.ts also gained the same disclosure redactHeaders carries: reassembleAnthropicSse, parseSseEvents and looksLikeSseStream are published exports with no in-tree caller — nothing in csuite holds a raw SSE body since capture became transcript- and OTEL-sourced. That is now stated in the header so the module's presence is not read as a live streaming-capture path.

    What remains unswept, stated rather than implied. The backticked-identifier method sees only claims that name a symbol. It cannot see a comment that describes a mechanism in prose without naming anything — the dual-auth and 1 MiB defects were both of that kind and both needed the vocabulary pass to find. Neither method reaches a comment that is wrong about behaviour while naming only symbols that still exist; that residue is unmeasured, and the four new redactJson contract tests are the pattern for closing it where it matters.

csuite

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

csuite-web-ui

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #51 ec161f2 Thanks @keencaliper! - Make activity and GenAI range reads losslessly pageable with composite timestamp/id cursors.

    TracePanel now traverses every page before joining activity with GenAI records, member activity pagination preserves rows sharing a timestamp, and GenAI enrichment failures are surfaced while retaining the marker-only fallback. Consumer-side history caps that silently discarded rows have been removed.

  • #51 ec161f2 Thanks @keencaliper! - FsEntry now carries canWrite — the server's own canWrite() answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner is obj:<id> and the rule includes objective membership, which a client cannot determine for an arbitrary path. The field is optional, so an older server that omits it still parses. The web UI's Delete control now asks rather than inferring, which restores it for objective members who were entitled to it all along.

  • #32 ee34ac0 Thanks @andrew-jon-p7a! - Replace the ad-hoc Unicode arrows on shell buttons with Lucide icons.

    Directional glyphs (, , , , , , ) were typed straight into button labels and breadcrumbs, so they rendered in the text font rather than from the icon set every other affordance draws from. They now come from the icon registry.

    • ArrowLeft / ArrowRight / ArrowUp are added to components/icons/index.ts and re-exported from the package root, alongside the existing chevrons.
    • Back links (Tools, Notifications, Secrets, Objectives, Home), forward actions (Manage, DM, Profile, View profile, Reassign, VIEW AGENT, Browse files, Open Files), submit buttons (Create + assign, Sign in), and the Load older pagers all render an icon plus a plain-text label.
    • The objective discussion's Send button now uses the Send icon, matching the composer.
    • Breadcrumb separators and the audit-log / API-call disclosure toggles use ChevronRight / ChevronUp / ChevronDown.
    • .crumbs gains inline-flex alignment so a crumb's icon and label share a centre line.

    Non-button arrows are untouched: assignee and delivery meta text, the in→out tok usage separator, and the file-type glyph vocabulary (, , , , , ) keep their existing characters.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.

csuite-web-host

Minor Changes

  • #51 ec161f2 Thanks @keencaliper! - Validate Broker.push's to so the broker cannot emit a Message its own schema rejects.

    push copied payload.to into message.to unchecked, so a csuite-core caller could
    make the broker produce a message that csuite-sdk's MessageSchema refuses to parse —
    one published artifact disagreeing with another. Confirmed by execution against the
    fetched 0.2.0 tarballs: push({to: 'chan:general'}) emitted to: 'chan:general' and
    MessageSchema.safeParse returned invalid_format at path to. push now rejects with
    InvalidRecipientError (exported from csuite-core) before the message is constructed or
    appended to the event log.

    The schema is the correct artifact here and push is the wrong one, which is the opposite
    of the FsEntry.owner call. chan: is a thread prefix carried in data.thread, never a
    recipient: channel sends leave to unset and pass the member list in
    PushContext.recipients. The live broker log agreed — 959 events, eight distinct to
    values, all member names or null, none the schema would reject.

    Fixing it surfaced a second and more serious defect on the same line. to: '' is falsy, so
    payload.to ?? null sent it down the registry.allStates() branch: a message addressed to
    one recipient was delivered to every registered member. Measured on the pre-change
    broker with three members registered — all three received it, targets: 3. That is why
    this rejects rather than coercing an unparseable name to null; the lenient repair is the
    empty-string path, generalised.

    This prevents the condition; it does not repair a log that already has one. The
    validation stops new rows being written and there is no migration — a Message with an
    invalid to already in an event log stays there. That matters more than it sounds,
    because HistoryResponseSchema is z.array(MessageSchema) and a single invalid element
    fails the whole array: one bad row breaks every /history response whose window includes
    it, taking the valid messages in that window with it, for as long as the row exists. So a
    consumer who already has one upgrades and sees /history still failing, and the fix looks
    like it did not work. Measured on our own broker: 959 events, zero rows the schema would
    reject, so nothing here needs repairing — but that is a statement about this deployment,
    not about anyone else's.

    This is breaking for direct csuite-core consumers. PushPayload.to is typed
    string | null | undefined, and some strings that type admits are now rejected at runtime.
    Two things bound it. Anyone this breaks was already producing invalid messages — a caller
    passing to: 'chan:general' today gets a Message that no schema-validating consumer can
    parse. And the HTTP plane is unaffected: /push already validates the body with
    PushPayloadSchema, whose to is NameSchema.nullable().optional(), so a bad recipient
    was rejected with a 400 before it ever reached the broker. The empty-string broadcast was
    never reachable over the wire.

    Released as a minor under the repository's pre-1.0 convention. The same change becomes
    major the day we ship 1.0.
    No protocol bump — the wire format is unchanged, and
    protocol.ts's rule is about what crosses the wire.

    Not covered: whether other Broker entry points emit values their schemas reject. This
    fixes one instance and the enumeration is not part of it.

  • #51 ec161f2 Thanks @keencaliper! - Stop secret lifecycle events leaking into every member's default history feed.

    Secret events are pushed to an explicit recipient set — the members bound to the secret
    plus every secrets.manage holder — and that part was already correct. But a fan-out push
    persists to_name = NULL, and the default feed returns every to_name IS NULL row to
    every viewer. So the live delivery was scoped and the durable readback was not.

    Measured on the live broker before the fix: 27 of 27 secret events were returned to a
    member who was neither bound to any of them nor held secrets.manage
    , reachable with a
    single recent call. The leaked body carries the secret slug, the environment variable
    name, and which member it was bound to — not the value, which is never included.

    csuite-core gains SECRET_THREAD_PREFIX and isSecretThread, and matchesViewer now
    excludes secret:-tagged rows from the default feed. The SQLite feed query carries the
    same exclusion so the two implementations answer the same question; each has a test
    asserting it.

    Excluded for every viewer rather than only for non-recipients, because the event log has
    no access to secret bindings and should not grow one. Nothing is stranded: GET /secrets
    returns per-viewer summaries and GET /secrets/resolve returns the caller's own env delta,
    which is the surface built for this. The rows remain in the log for forensics; it is the
    feed that stops carrying them. Live push behaviour is unchanged — the notification is
    still the only signal that a runner restart is needed to pick a secret up.

    Released as a minor under the repository's pre-1.0 convention: it removes rows a consumer
    could previously read, which is behaviour-breaking for anyone who was reading them — and
    anyone who was reading them was reading other members' secret metadata.

    Not fixed here, and it is larger than this. The same to_name IS NULL feed also
    returns objective discussion threads to members who are not the originator, assignee or a
    watcher. Measured on the same broker: one objective's 31 thread messages are returned to a
    member who is not on it. That is the same defect with a different tag, and it needs an
    entitlement-aware feed rather than a prefix exclusion, because — unlike secrets — objective
    threads have no alternative read surface today. Fixing it inside this change would have
    made a bounded repair unbounded.

    Also unaddressed: EventLog.tail() applies no viewer scoping at all. It currently has no
    callers, so it is a latent hazard rather than a live leak.

Patch Changes

  • #32 ee34ac0 Thanks @andrew-jon-p7a! - Replace the ad-hoc Unicode arrows on shell buttons with Lucide icons.

    Directional glyphs (, , , , , , ) were typed straight into button labels and breadcrumbs, so they rendered in the text font rather than from the icon set every other affordance draws from. They now come from the icon registry.

    • ArrowLeft / ArrowRight / ArrowUp are added to components/icons/index.ts and re-exported from the package root, alongside the existing chevrons.
    • Back links (Tools, Notifications, Secrets, Objectives, Home), forward actions (Manage, DM, Profile, View profile, Reassign, VIEW AGENT, Browse files, Open Files), submit buttons (Create + assign, Sign in), and the Load older pagers all render an icon plus a plain-text label.
    • The objective discussion's Send button now uses the Send icon, matching the composer.
    • Breadcrumb separators and the audit-log / API-call disclosure toggles use ChevronRight / ChevronUp / ChevronDown.
    • .crumbs gains inline-flex alignment so a crumb's icon and label share a centre line.

    Non-button arrows are untouched: assignee and delivery meta text, the in→out tok usage separator, and the file-type glyph vocabulary (, , , , , ) keep their existing characters.

  • #51 ec161f2 Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source.

  • #51 ec161f2 Thanks @keencaliper! - Allow two secrets to target the same environment variable for different members.

    Registering a second secret with an env name already in use returned 409 Conflict,
    because env_name carried a global unique index. The rationale in the source was correct —
    "a member's resolved env map can never carry two values for one variable" — but the
    constraint enforcing it was far stronger than the invariant it protected.

    The result is that the per-agent pattern the product is built around was impossible:
    cora-github-token targeting GITHUB_TOKEN permanently blocked rune-github-token
    targeting GITHUB_TOKEN, even though no member is ever bound to both. Provisioning a
    second agent with its own credentials failed on the second secret.

    The invariant is now enforced where it can first be violated:

    • bind() refuses when the member already resolves that variable from another secret.
      This is the real check — binding is the first moment a targeted secret reaches anyone.
    • create()/update() additionally refuse for allMembers secrets, which reach
      everyone without a binding and so can collide before any bind happens.
    • update() re-checks against the audience the secret will have after the patch, so
      widening to all-members or repointing at a new variable can't slip a collision through.

    The global unique index is dropped in the schema DDL (DROP INDEX IF EXISTS), which
    migrates existing databases and fresh ones by the same path. Without the drop, an existing
    deployment would keep rejecting the second binding with a raw SQLite constraint error
    instead of a mapped one.

    POST /secrets/:slug/bindings now maps SecretsError through mapSecretsError, so the
    new conflict surfaces as 409 rather than an unhandled 500.

    Behaviour that changed, deliberately: two previously-passing tests asserted the old
    global uniqueness, and both were rewritten rather than deleted — one now asserts that a
    second secret on the same variable is accepted, and the update test asserts a conflict
    only once two secrets actually share a member. The store's header docblock stated the old
    rule and has been corrected; a comment that contradicts the code is a defect in its own
    right.

    Released as a patch: it removes a restriction rather than adding or breaking one, and no
    caller that previously succeeded now fails.