CommandSuite v0.3.0
csuite-cli
Minor Changes
-
#51
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @keencaliper! - Fixobjectives_listreturning only assigned work to any caller holdingobjectives.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.creategot exactly what the description promised.handleObjectivesListunconditionally sentassignee: <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.ListObjectivesQuerygainsrelated, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction asassigneefor members lackingobjectives.create. The MCP tool now sendsrelatedinstead ofassignee.The union is deliberately not the default for a privileged caller with no filter: the director dashboard (
web-ui/src/lib/objectives.ts) callslistObjectives()bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies onassigneestaying 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
relatedentirely. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @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
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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
ec161f2Thanks @keencaliper! - Fix therosterMCP 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-facingrostertool interpolated it straight into its output line, so every agent that ever calledrostergot- 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 readrole.title; only the agent's view was broken.The tool now renders
role.title, matchingcsuite 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
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through itsexportsmap intodist/, and nothing in module resolution checks thatdist/was built from thesrc/on disk — so a stale build produced a green suite that proved nothing. Turbo'sdependsOn: ["^build"]covered the rootpnpm testand nothing else; a filteredpnpm --filter <pkg> exec vitest runbypassed it entirely. -
#51
ec161f2Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.Security-relevant.
core/src/trace/redact.tsopened 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:redactHeadersis a published export ofcsuite-corewith 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 thatredactHeadersis 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-auth→tri-authon 18 lines across 5 source files, not the 5 sites originally reported: eleven inserver/src/app.ts, four insdk/src/protocol.ts, one each incore/src/session-store.ts,server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — andserver/src/auth.tsalready 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.tslines were missed by the first pass and found in verification: the defining paragraph at the top of the file still readDual-auth = either bearer or cookiedirectly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources,GET /secrets) still saidDual-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 inauth.test.tswas 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.tssaid 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 insession_end.capture.sdk/src/types.tsjustified hook-sourceduser_promptcapture 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'sresultis 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_createtitle (200), outcome (2048), body (4096), watchers (64), attachments (64);objectives_discussbody (16384), attachments (64);objectives_updateblockReason (2048);objectives_completeresult (4096);objectives_watchersadd (64) and remove (64); andbroadcast,send, andchannels_postbody (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:
DiscussObjectiveRequestSchemaandPushPayloadSchemaboth accept an optionaltitlethat 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.tspollUrlon the/enrollresponseno such field — the response carries relative verificationUri/verificationUriCompleteand no poll hintcore/src/trace/sse.tsbuildAnthropicEntryinanthropic.tsanthropicToGenAiingenai.tscore/src/trace/transcript.tsparseMessagesin the HTTP pathanthropicToGenAiserver/src/members.ts(×2)TeamConfigSchemaServerConfigSchemaserver/src/run.tsloadTeamConfigFromFileloadServerConfigFromFileserver/src/team-store.tsseedTeamsetTeamserver/src/files/filesystem-store.tssrcPathsrcweb-ui/src/lib/client.tsthe __resetForTestsconvention__reset<Module>ForTestsThe
sdk/src/types.tsone 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.tsalso gained the same disclosureredactHeaderscarries:reassembleAnthropicSse,parseSseEventsandlooksLikeSseStreamare 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-authand 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 newredactJsoncontract tests are the pattern for closing it where it matters. -
#51
ec161f2Thanks @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 insession_end.capture.
csuite-server
Minor Changes
-
#51
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @keencaliper! - Fixobjectives_listreturning only assigned work to any caller holdingobjectives.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.creategot exactly what the description promised.handleObjectivesListunconditionally sentassignee: <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.ListObjectivesQuerygainsrelated, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction asassigneefor members lackingobjectives.create. The MCP tool now sendsrelatedinstead ofassignee.The union is deliberately not the default for a privileged caller with no filter: the director dashboard (
web-ui/src/lib/objectives.ts) callslistObjectives()bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies onassigneestaying 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
relatedentirely. -
#51
ec161f2Thanks @keencaliper! - Reassigning an objective no longer strips the previous assignee from its discussion thread.Thread membership is computed, not stored —
objectiveThreadMembersderives 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 returned403: 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.reassignnow promotes the outgoing assignee to watcher in the same transaction, emitting awatcher_addedevent carryingreason: '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
watchersis 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
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @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
ec161f2Thanks @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
ec161f2Thanks @keencaliper! -FsEntrynow carriescanWrite— the server's owncanWrite()answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner isobj:<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
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through itsexportsmap intodist/, and nothing in module resolution checks thatdist/was built from thesrc/on disk — so a stale build produced a green suite that proved nothing. Turbo'sdependsOn: ["^build"]covered the rootpnpm testand nothing else; a filteredpnpm --filter <pkg> exec vitest runbypassed it entirely. -
#51
ec161f2Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.Security-relevant.
core/src/trace/redact.tsopened 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:redactHeadersis a published export ofcsuite-corewith 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 thatredactHeadersis 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-auth→tri-authon 18 lines across 5 source files, not the 5 sites originally reported: eleven inserver/src/app.ts, four insdk/src/protocol.ts, one each incore/src/session-store.ts,server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — andserver/src/auth.tsalready 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.tslines were missed by the first pass and found in verification: the defining paragraph at the top of the file still readDual-auth = either bearer or cookiedirectly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources,GET /secrets) still saidDual-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 inauth.test.tswas 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.tssaid 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 insession_end.capture.sdk/src/types.tsjustified hook-sourceduser_promptcapture 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'sresultis 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_createtitle (200), outcome (2048), body (4096), watchers (64), attachments (64);objectives_discussbody (16384), attachments (64);objectives_updateblockReason (2048);objectives_completeresult (4096);objectives_watchersadd (64) and remove (64); andbroadcast,send, andchannels_postbody (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:
DiscussObjectiveRequestSchemaandPushPayloadSchemaboth accept an optionaltitlethat 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.tspollUrlon the/enrollresponseno such field — the response carries relative verificationUri/verificationUriCompleteand no poll hintcore/src/trace/sse.tsbuildAnthropicEntryinanthropic.tsanthropicToGenAiingenai.tscore/src/trace/transcript.tsparseMessagesin the HTTP pathanthropicToGenAiserver/src/members.ts(×2)TeamConfigSchemaServerConfigSchemaserver/src/run.tsloadTeamConfigFromFileloadServerConfigFromFileserver/src/team-store.tsseedTeamsetTeamserver/src/files/filesystem-store.tssrcPathsrcweb-ui/src/lib/client.tsthe __resetForTestsconvention__reset<Module>ForTestsThe
sdk/src/types.tsone 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.tsalso gained the same disclosureredactHeaderscarries:reassembleAnthropicSse,parseSseEventsandlooksLikeSseStreamare 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-authand 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 newredactJsoncontract tests are the pattern for closing it where it matters.
csuite-core
Minor Changes
-
#51
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @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
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through itsexportsmap intodist/, and nothing in module resolution checks thatdist/was built from thesrc/on disk — so a stale build produced a green suite that proved nothing. Turbo'sdependsOn: ["^build"]covered the rootpnpm testand nothing else; a filteredpnpm --filter <pkg> exec vitest runbypassed it entirely. -
#51
ec161f2Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.Security-relevant.
core/src/trace/redact.tsopened 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:redactHeadersis a published export ofcsuite-corewith 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 thatredactHeadersis 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-auth→tri-authon 18 lines across 5 source files, not the 5 sites originally reported: eleven inserver/src/app.ts, four insdk/src/protocol.ts, one each incore/src/session-store.ts,server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — andserver/src/auth.tsalready 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.tslines were missed by the first pass and found in verification: the defining paragraph at the top of the file still readDual-auth = either bearer or cookiedirectly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources,GET /secrets) still saidDual-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 inauth.test.tswas 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.tssaid 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 insession_end.capture.sdk/src/types.tsjustified hook-sourceduser_promptcapture 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'sresultis 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_createtitle (200), outcome (2048), body (4096), watchers (64), attachments (64);objectives_discussbody (16384), attachments (64);objectives_updateblockReason (2048);objectives_completeresult (4096);objectives_watchersadd (64) and remove (64); andbroadcast,send, andchannels_postbody (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:
DiscussObjectiveRequestSchemaandPushPayloadSchemaboth accept an optionaltitlethat 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.tspollUrlon the/enrollresponseno such field — the response carries relative verificationUri/verificationUriCompleteand no poll hintcore/src/trace/sse.tsbuildAnthropicEntryinanthropic.tsanthropicToGenAiingenai.tscore/src/trace/transcript.tsparseMessagesin the HTTP pathanthropicToGenAiserver/src/members.ts(×2)TeamConfigSchemaServerConfigSchemaserver/src/run.tsloadTeamConfigFromFileloadServerConfigFromFileserver/src/team-store.tsseedTeamsetTeamserver/src/files/filesystem-store.tssrcPathsrcweb-ui/src/lib/client.tsthe __resetForTestsconvention__reset<Module>ForTestsThe
sdk/src/types.tsone 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.tsalso gained the same disclosureredactHeaderscarries:reassembleAnthropicSse,parseSseEventsandlooksLikeSseStreamare 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-authand 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 newredactJsoncontract tests are the pattern for closing it where it matters.
csuite-sdk
Minor Changes
-
#51
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @keencaliper! - Fixobjectives_listreturning only assigned work to any caller holdingobjectives.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.creategot exactly what the description promised.handleObjectivesListunconditionally sentassignee: <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.ListObjectivesQuerygainsrelated, the explicit relationship scope — assigned OR originated OR watching — applied for every caller, with the same self-only restriction asassigneefor members lackingobjectives.create. The MCP tool now sendsrelatedinstead ofassignee.The union is deliberately not the default for a privileged caller with no filter: the director dashboard (
web-ui/src/lib/objectives.ts) callslistObjectives()bare and relies on team-wide, and the runner's plate snapshot (objectives-tracker.ts) relies onassigneestaying 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
relatedentirely. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @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
ec161f2Thanks @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
ec161f2Thanks @keencaliper! -FsEntrynow carriescanWrite— the server's owncanWrite()answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner isobj:<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
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - Refuse to run tests against a build that no longer matches its source. Tests that import a workspace package by name resolve through itsexportsmap intodist/, and nothing in module resolution checks thatdist/was built from thesrc/on disk — so a stale build produced a green suite that proved nothing. Turbo'sdependsOn: ["^build"]covered the rootpnpm testand nothing else; a filteredpnpm --filter <pkg> exec vitest runbypassed it entirely. -
#51
ec161f2Thanks @keencaliper! - Correct source comments and MCP tool descriptions that assert mechanisms, guarantees, or constants the code contradicts.Security-relevant.
core/src/trace/redact.tsopened 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:redactHeadersis a published export ofcsuite-corewith 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 thatredactHeadersis 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-auth→tri-authon 18 lines across 5 source files, not the 5 sites originally reported: eleven inserver/src/app.ts, four insdk/src/protocol.ts, one each incore/src/session-store.ts,server/src/sessions.ts, and a test header. Auth has three planes — opaque bearer, session cookie, optional RS256 JWT — andserver/src/auth.tsalready 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.tslines were missed by the first pass and found in verification: the defining paragraph at the top of the file still readDual-auth = either bearer or cookiedirectly above routes the same pass had relabelled tri-auth, and two route comments (GET /tool-sources,GET /secrets) still saidDual-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 inauth.test.tswas 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.tssaid 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 insession_end.capture.sdk/src/types.tsjustified hook-sourceduser_promptcapture 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'sresultis 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_createtitle (200), outcome (2048), body (4096), watchers (64), attachments (64);objectives_discussbody (16384), attachments (64);objectives_updateblockReason (2048);objectives_completeresult (4096);objectives_watchersadd (64) and remove (64); andbroadcast,send, andchannels_postbody (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:
DiscussObjectiveRequestSchemaandPushPayloadSchemaboth accept an optionaltitlethat 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.tspollUrlon the/enrollresponseno such field — the response carries relative verificationUri/verificationUriCompleteand no poll hintcore/src/trace/sse.tsbuildAnthropicEntryinanthropic.tsanthropicToGenAiingenai.tscore/src/trace/transcript.tsparseMessagesin the HTTP pathanthropicToGenAiserver/src/members.ts(×2)TeamConfigSchemaServerConfigSchemaserver/src/run.tsloadTeamConfigFromFileloadServerConfigFromFileserver/src/team-store.tsseedTeamsetTeamserver/src/files/filesystem-store.tssrcPathsrcweb-ui/src/lib/client.tsthe __resetForTestsconvention__reset<Module>ForTestsThe
sdk/src/types.tsone 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.tsalso gained the same disclosureredactHeaderscarries:reassembleAnthropicSse,parseSseEventsandlooksLikeSseStreamare 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-authand 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 newredactJsoncontract tests are the pattern for closing it where it matters.
csuite
Minor Changes
-
#51
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ec161f2Thanks @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
ec161f2Thanks @keencaliper! -FsEntrynow carriescanWrite— the server's owncanWrite()answer for the requesting viewer. Clients no longer have to rebuild that rule, and for objective-namespace entries they could not: the owner isobj:<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
ee34ac0Thanks @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/ArrowUpare added tocomponents/icons/index.tsand 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 theLoad olderpagers all render an icon plus a plain-text label. - The objective discussion's
Sendbutton now uses theSendicon, matching the composer. - Breadcrumb separators and the audit-log / API-call disclosure toggles use
ChevronRight/ChevronUp/ChevronDown. .crumbsgains 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 tokusage separator, and the file-type glyph vocabulary (▸,▶,◈,≡,⧉,◆) keep their existing characters. -
#51
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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
ec161f2Thanks @keencaliper! - ValidateBroker.push'stoso the broker cannot emit aMessageits own schema rejects.pushcopiedpayload.tointomessage.tounchecked, so acsuite-corecaller could
make the broker produce a message thatcsuite-sdk'sMessageSchemarefuses to parse —
one published artifact disagreeing with another. Confirmed by execution against the
fetched0.2.0tarballs:push({to: 'chan:general'})emittedto: 'chan:general'and
MessageSchema.safeParsereturnedinvalid_formatat pathto.pushnow rejects with
InvalidRecipientError(exported fromcsuite-core) before the message is constructed or
appended to the event log.The schema is the correct artifact here and
pushis the wrong one, which is the opposite
of theFsEntry.ownercall.chan:is a thread prefix carried indata.thread, never a
recipient: channel sends leavetounset and pass the member list in
PushContext.recipients. The live broker log agreed — 959 events, eight distinctto
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 ?? nullsent it down theregistry.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 — aMessagewith an
invalidtoalready in an event log stays there. That matters more than it sounds,
becauseHistoryResponseSchemaisz.array(MessageSchema)and a single invalid element
fails the whole array: one bad row breaks every/historyresponse 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/historystill 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-coreconsumers.PushPayload.tois 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
passingto: 'chan:general'today gets aMessagethat no schema-validating consumer can
parse. And the HTTP plane is unaffected:/pushalready validates the body with
PushPayloadSchema, whosetoisNameSchema.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
Brokerentry points emit values their schemas reject. This
fixes one instance and the enumeration is not part of it. -
#51
ec161f2Thanks @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 everysecrets.manageholder — and that part was already correct. But a fan-out push
persiststo_name = NULL, and the default feed returns everyto_name IS NULLrow 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 heldsecrets.manage, reachable with a
singlerecentcall. 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-coregainsSECRET_THREAD_PREFIXandisSecretThread, andmatchesViewernow
excludessecret:-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 andGET /secrets/resolvereturns 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 NULLfeed 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
ee34ac0Thanks @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/ArrowUpare added tocomponents/icons/index.tsand 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 theLoad olderpagers all render an icon plus a plain-text label. - The objective discussion's
Sendbutton now uses theSendicon, matching the composer. - Breadcrumb separators and the audit-log / API-call disclosure toggles use
ChevronRight/ChevronUp/ChevronDown. .crumbsgains 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 tokusage separator, and the file-type glyph vocabulary (▸,▶,◈,≡,⧉,◆) keep their existing characters. -
#51
ec161f2Thanks @keencaliper! - Refuse manual publication unless package payloads were prepared from clean, committed source. -
#51
ec161f2Thanks @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,
becauseenv_namecarried 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-tokentargetingGITHUB_TOKENpermanently blockedrune-github-token
targetingGITHUB_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 forallMemberssecrets, 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/bindingsnow mapsSecretsErrorthroughmapSecretsError, 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.