Skip to content

fix(gate): name every problem a wake rule has, and read gate.json's as it ran - #452

Merged
nissa-seru merged 33 commits into
anima-research:mainfrom
nissa-seru:fix/strict-gate-validation
Oct 23, 2026
Merged

nissa-seru merged 33 commits into
anima-research:mainfrom
nissa-seru:fix/strict-gate-validation

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: every problem a wake rule has is now named. A typo in its behavior, a match field of the wrong type, a misspelled field name and a field put at the wrong level each used to change what the rule does without a word. wake_add_rule and addGatePolicy refuse such a rule, naming every problem, and save nothing. gate.json and the host's starting rules are what a deployment already runs, so there a rule with a problem is read the way the gate read it before, and named, and the rest of the file loads: wherever the gate loaded the file before, no event that woke the agent goes quiet. A rule is left out only where the gate refused it before. Under a "skip" or "defer" default, a file that would leave out a rule that could wake the agent isn't used, as before.

Problem

Each of these was silent, in gate.json and through wake_add_rule alike. We met them while rewording the gate's messages, and checked each with a probe at main (92125b07) with membrane 0.5.87.

  • A behavior the gate doesn't know was saved as "always", a rule that wakes on every event it matches. validatePolicy's last else covered any behavior that isn't "always", "defer", "skip" or an object, so a typo such as "deffer" gave "always". So did the object form in membrane's XML tool mode, which parses a parameter by its declared type: wake_add_rule declares behavior a string, so the description's own example, behavior: { debounce: 60000 }, arrived as the text {"debounce": 60000}.
  • A match field of the wrong type was dropped, so the rule matched everything that field was meant to narrow. { match: { tagsAny: "chat:ambient" }, behavior: "skip" } was saved as match: {}, a rule that skips every event, DMs and mentions included. The same went for scope as a string, channel as a number, match as a string, and a filter whose type isn't text or regex.
  • A misspelled field was ignored, so the field meant was missing. pasthrough: true gave a governor that consumes the events it should let fall through. keyby gave one rate-limit bucket for every channel, and polices in gate.json gave no rules at all. wake_add_rule took its own parameters by name, so a misspelled one was dropped the same way.
  • A field at the wrong level was ignored too. { name: "quiet", tagsAny: ["chat:ambient"], behavior: "skip" }, with tagsAny beside name rather than in match, skipped every event, mentions included, and addPolicy saved it as match: {}. A filter's pattern put in match was dropped the same way. wake_add_rule given tagsAny as a parameter of its own saved its rule with match: {} too. A list of rules under a name gate.json doesn't read, such as "policy", and a field of a rule, or of its match or behavior, put at gate.json's top level loaded as no rules under its default, which under "skip" or "defer" holds back every event, and a change wrote the rules in force over the file, dropping them.
  • The rest: a behavior object holding two kinds used debounce, then rate_limit, then passive_sample, whatever their order. A keyBy or resets of the wrong type was dropped. A gate.json whose policies isn't a list, or which is itself a list, loaded as no rules under its default.
  • The host's starting rules (FrameworkConfig.gate.config, which connectome-host fills from a recipe's wake block unchecked) were seeded into gate.json as given. When a rule among them failed the gate's checks, that gate.json then failed to load, and the gate ran every starting rule as given, from memory.
  • A failed write of them went unmentioned. When adding the starting rules to a gate.json that loaded failed, the load that followed cleared the error, so the missing rules went unmentioned. A write that failed partway left gate.json unable to load, and the start then dropped the rules it had held.

Changes

  • Every problem a rule has is named, at every level: gate.json itself, the rule, match, filter, behavior, rate_limit and passive_sample. That covers a field of the wrong type, an entry of the wrong type or "" in a list, a behavior that isn't one of the three names or an object holding one of debounce, rate_limit and passive_sample, two kinds in one behavior, and a field name that's a slip for one its level reads: the same ignoring case, one edit away or more for a long name (a quarter of what follows the part the two names share), or, at three characters or more, a part of it. Below gate.json's own top, a name holding the whole name of a field its object doesn't give is a slip of that field too, the field under another name, such as matchTags with no match, and one holding two such names is a slip of each. A slip is named with every field it could be meant for: match has "tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?), which the gate doesn't read. A field the gate reads at another level is named with where it's read: it has "tagsAny", a field of "match", which the gate doesn't read here, for a match field beside name, and a field of gate.json for gate.json's default in a rule. This uses src/closest-names.ts, which fix: tool presentation and code_execution errors say what happened and what helps #413 added to main to suggest a tool when a tool presentation names one that doesn't exist.
  • wake_add_rule and addGatePolicy refuse a rule with any problem, since their caller is there to fix it, naming every problem the rule has, and save nothing: Policy "watch" wasn't saved: behavior.debounce is 50 ms, under the 100 ms minimum; match.tagsAny is "chat:ambient", where a list of non-empty strings is needed, such as ["chat:ambient"]. wake_add_rule checks its own parameters before the rule, each refused on its own. A behavior object holding two kinds or three is read as the first it holds of debounce, rate_limit and passive_sample, as before, and a refusal names the problems of each kind it holds, so whichever kind the caller keeps, its problems are already named.
  • In gate.json and the host's starting rules, a rule with a problem is read the way the gate read it before, with a note in gate_status's errors and a [gate] log line, and the rest of the file loads: gate.json loaded. Policy "x": behavior is "deffer", where "always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample" is needed, so it's read as "always". These are what a deployment already runs, and refusing a whole file for a slip that silenced nothing could silence events its other rules woke on: at a start, the starting rules would run in its place, and a recipe's "skip" default would then hold back a mention the operator's own rules woke on. So the change promises: no event that woke the agent before is silenced, unless the gate refused the whole file and so ran something else in its place (at a start, the host's starting rules or none; at a reload, the rules it had), or refused one of the host's starting rules and so ran them all as given. What a deployment will notice names the one case of that left once the next bullets apply.
    • Read the way the gate read it, each named: a behavior that isn't a name or an object (a typo, a number, the object form as text) as "always", and so a slip of behavior itself; two kinds as the first of debounce, rate_limit and passive_sample; a match that isn't an object as matching every event; and a match field of the wrong type, a slip of one, a list entry of the wrong type or "", a filter it couldn't read, a field put at a level where the gate doesn't read it, and a keyBy or resets of the wrong type, or a slip of keyBy, resets or passthrough, each dropped. In a rule the gate reads, a match left with nothing that narrows it, once something in it was dropped, left out as "" or read nowhere, gets a note saying it matches every event: Policy "quiet": nothing left in match narrows the rule, so it matches every event. In a refusal of such a rule, where nothing was saved, the note gives it as the reason the problems before it matter: Read without what's wrong in its match, as earlier releases read it, the rule would match every event. Where that reading held back more than the rule meant, as a misspelled passthrough does, it holds the same back now, and the note says so. That goes for a rule that skips or defers too, whose note ends Read so, it skips every event it now matches, as earlier releases did.
    • Left out, where the gate refused the rule before, so the exception covers it: a rule that isn't an object or has no name; a behavior that's a list or an object holding no kind; a rate_limit or passive_sample that isn't an object or lacks tokens, refillIntervalMs or every; a debounce, tokens, refillIntervalMs or every that isn't a finite number; a rate limit with tokens at or under 0, which woke on nothing; an invalid regular expression in a filter; and a passthrough that isn't a boolean, or is true on a behavior that doesn't count. The note says it's left out and why: Policy "x" is left out: behavior.rate_limit.tokens is 0, where a positive number is needed.
    • Not left out where it skips or defers. Left out, a rule that skips or defers would let the events it held back go on to the rules after it, and a rate limit, a sample or a debounce there would spend them and then hold back an event that woke the agent before. A probe sends one interleaved stream of events through the gate before this change and through this change, with a skip or defer rule that has a problem ahead of a rate limit, a sample or a debounce. Where an earlier version of this change left such rules out, it found 72 events silenced in 64 of 144 configurations, each through a rate limit or a sample. Read as before, every event's decision is the one the gate made before, whatever follows the rule: at this head, the probe finds the same decision on both for every event, in all 144 configurations. An empty entry in scope, which the gate kept, only narrows a rule, since no event's type is "".
    • Not used, under a default that holds events back: with "skip" or "defer", a file that leaves out a rule that could wake the agent would hold back exactly what that rule was written to wake on, and the gate, which refused such a rule and so the file, didn't use it before either. So it isn't used: a start runs the host's starting rules, or none, which hears every event, and a reload keeps the rules in force. The refusal names each such rule and why it's left out: gate.json leaves out a rule that could wake the agent, and under its default, "skip", what it was written to wake it on would be held back. Policy "mentions" is left out: behavior is [], where … is needed. A rule left out that wakes on none of what it matches, one that skips or defers or a rate limit whose tokens are a number at or under 0, holds back nothing it was written to wake on, so it doesn't count, and a file kept quiet on purpose loads as it did. What it matches goes on to the rules after it, as for any rule left out. One with tokens missing or not a number, or a behavior that's a list or names no kind, was written to wake on something, so it counts.
  • A number outside the gate's range is read with a note, in gate.json, which the gate refused whole for one before, and in the starting rules, which it ran as given. No event the starting rules woke the agent on is held back, though a batch can go out later or sooner. A debounce under 100 ms is read as 100 ms, so events further apart than the debounce given and closer than 100 ms, each its own batch before, share one. One over 300000 ms is read as 300000 ms, so its batch goes out sooner, but for one over 2147483647 ms, the most a timer takes, which Node and Bun 1.4.3 ran after 1 ms, and whose batch now waits five minutes. A passive_sample every at or under 0 is read as 1, and a fractional one as its next whole number, as its counter's check ran them. A rate_limit refillIntervalMs under 0 is read as a bucket that never refills, as it ran, so the rule wakes on its first tokens events and none after. One of 0 refills at once, so the rule limits nothing: run from the starting rules, it refilled the bucket once, since floor(elapsed / 0) is Infinity, and then never, since the bucket's clock became NaN. applyRateLimit refills at once for 0 and never under it, and so does the probe that wake_add_rule's before-and-after table reads. wake_add_rule and addGatePolicy refuse each, as before.
  • gate.json itself: a file the gate loaded loads as it read it, with a note. One that's a list is read as no rules under the default "always". A slip of policies, such as "polices", a name the gate doesn't read, rules parked under one, such as "disabled", "rules" or "policy", included, a field of a rule, or of its match or behavior, at the top, and a policies that isn't a list each do nothing, and a note names each, a slip with what the file's default then does: gate.json has "polices" (did you mean "policies"?), which the gate doesn't read, and it has no rules, so under its default, "skip", every event is held back. A parked list is a name like any other, since nothing in a name tells rules parked on purpose from a misnamed list: gate.json has "disabled", which the gate doesn't read, so it does nothing. …. A field at the top is named with where it's read: gate.json has "channel", a field of "match", which the gate doesn't read here, so it does nothing. Where policies writes no rules, the file has none, so its default decides every event, as before: under "skip" or "defer", it holds back every one. A policies holding nothing, such as "policies": {}, writes no rules, as [] does. A slip of default reads as "always", as before, and so does a "" default, which the gate refused whole before. JSON that won't parse, a non-object and any other default than the three are refused whole, as before. A policies left out, null, "" or [] writes no rules: the file loads as no rules under its default, as before, and a "" says what that does, under "skip" or "defer" that every event is held back.
  • A change edits gate.json's own list of rules by name: wake_add_rule, wake_remove_rule, addGatePolicy and removeGatePolicy add, replace, move or remove the rule they name, and write back every other rule as it was written, with its problems and the fields the gate doesn't read, and gate.json's own fields with them. The rules in force are then read from what was written, as a load reads it, so what gate.json still holds that the gate reads past or leaves out stays named until someone fixes it. A rule the gate leaves out can be replaced, or removed, by its name. A rule the written text leaves out of force, or gives another behavior, loses its buckets, counters and timers, as at a reload, so a rule that comes back, or that a change gives another behavior, starts afresh. A write then sends each such rule's pending debounce batch on its own, as its timer would have sent it, held while an agent is inferring or the host is quiesced, once the written rules are in force and every such rule's state is cleared. A reload sends it the same way, where main delivered it at once. A batch whose delivery throws is named in a [gate] log line and dropped, the rest are still sent, and the change or reload goes on as made, since it has done what it was asked. So is one its timer sends, or one held for an agent's turn or the host's quiesce, which threw uncaught before, stopping a host with no handler for that. Rules that share a name share its state, and a change or reload keeps it only where those rules keep their behaviors, in order: main's reload compared each with the last of them, so with two rules of one name it reset their state at a reload that changed neither, and kept it where giving the first the last one's behavior changed it. Before, a write wrote the rules as the gate held them, so a "comment" didn't survive one, and with this change's readings a rule left out would have dropped from the file and "deffer" come back as "always". A gate.json that's JSON the gate doesn't use, such as one the condition above takes out of force, is edited the same way, and only what loads is written. A change that doesn't mend what keeps the file out is refused, naming that and what would bring it into force, worked out from the file as it is, and the file stays as its operator wrote it: Nothing was saved: with this change, gate.json wouldn't be used. gate.json leaves out a rule that could wake the agent, … Replacing or removing "quiet-ambient" by name would bring gate.json into force. A change that replaces or removes that rule by its name brings the file into force, unless it would leave the file no rules in force: such a file holds events back by default, so it would then hold back every event, those the rules in force wake on included, and the change is refused, naming what runs meanwhile: Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every event. Meanwhile the gate runs the host's starting rules. Replacing "x" by name would bring gate.json into force. There the refusal offers a replacement alone, and 0.26.0's removal answered false and kept the rules in force. Where no one change can, since a rule with no name keeps it out, or two rules do, or its default isn't one of the three, the refusal says so: No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; until then, a change is refused the same way. Where the rule shares its name with another, a change by that name replaces the first of them or removes them all, and the refusal says that, rather than name a change that would replace or remove a sound rule: gate.json has 2 rules named "a", and a change by that name replaces the first of them or removes them all. Before, a change wrote the rules in force over such a file, losing its other rules, its own fields and its default. When gate.json is missing, a change edits the list of the text whose rules are in force the same way, so a rule that text leaves out keeps its name there too; the rules in force are written only where there's no such text, or it has no rule list. A gate.json that could be read but isn't JSON refuses a change too, naming the parse error and what the gate runs meanwhile, before an anchor or a name is looked for, so a removal of any name is refused as well, since the file may hold it, and nothing is written over it, so an operator's unfinished edit stays as it is: gate.json isn't JSON the gate can read (<error>), so a change can't edit its rules without writing over what it holds, and nothing was saved. Meanwhile the gate runs the rules of the last gate.json it used. Until gate.json is JSON, a change is refused the same way. The pull request this builds on kept a copy of such a file and then wrote the rules in force over it. Once a change edits gate.json's own list, that file was the one case the copy still served, so the copy goes, with all that reported it: UnreadableConfigKept, GateStatus.keptCopies, onUnreadableKept, keptUnreadable and the gate:config-kept trace. A gate.json the start's own write left part-written isn't JSON either, and is refused the same way, while the gate runs what that start read and what it was adding. Where gate.json can't be read at all, a change is refused, as the pull request this builds on refuses it, and the refusal says what runs and who can change that: Whoever runs this host can make gate.json readable; until then, a change is refused the same way. A gate.json with no list of rules a change could edit without writing over what it holds, a list, a policies that isn't one and holds something, or JSON that isn't an object, refuses a change, an addition or a removal, whatever its default, naming what runs meanwhile and what has to change, and saves nothing: gate.json is a list, so it has no list of rules a change could edit without writing over what it holds, and nothing was saved. Meanwhile the gate runs no rules: every event gets the default, "always". Until gate.json is an object whose "policies" is a list holding its rules, a change is refused the same way. Where the fix is an edit of gate.json, these texts and the start's say what has to change rather than who can change it, since whoever can edit the file may be the agent; where only the host's operator can act, as for a file the gate can't read or a write that failed, they name the operator. A write of gate.json that fails says whether gate.json was changed: one that fails at its open leaves it as it was, and one that fails after may leave it holding the whole change, which the next reload reads, part of it or nothing, and the rules in force stay as they were. The refusal at a start that didn't use gate.json says what runs meanwhile too, and that gate_status says what keeps gate.json out of use. A removal there used to answer that no rule had the name, though the file can hold it, and an addition used to write the rules in force over the file. Beside a slip of policies, rules under another name or a rule's fields at the top, a change adds a policies list and keeps them as written, where an addition used to write the rules in force over the file, dropping them.
  • Loaded, and named: any other name the gate reads nowhere, since it does nothing now and did nothing before, including a name further from a field than a slip, such as note in match, and one holding a field's whole name beside that field, such as channelNote beside channel, or at gate.json's own top, where it can be a label of the file, such as policiesUpdated: gate.json loaded. Policy "dm" has "comment", which the gate doesn't read, so it does nothing. Its fields are …. A "__proto__" is such a name: it stays a field, so what it holds narrows nothing. wake_add_rule leaves it out of the rule it saves and names it in the result's ignoredFields. addGatePolicy takes an optional onIgnoredFields callback for the same notes, and logs them without one. A refusal also carries its rule's notes, since an unknown field is often the one a missing field was meant to be (refillEveryMs for refillIntervalMs).
  • Left out: a null, and a "", which a note names, saying what leaving it out does where that's more than no constraint: a match left out matches every event, and so does one whose every field is ""; a behavior is "always"; a keyBy leaves one bucket, or one count, for every event the rule matches; and gate.json's policies leaves no rules, which under "skip" or "defer" holds back every event. The gate read most fields that way: it kept "" in source, channel, mount, pathGlob and a filter's pattern, wrote it from wake_add_rule calls that worked, and read it as no constraint, so a gate.json holding one loads as it did. Where it refused a rule or a gate.json for a null or "", in a passthrough, a behavior's kind or gate.json's default, it now loads without it. A filter whose pattern is "" narrows nothing, and is left out, with a note saying so. An absent field keeps its default (behavior "always", match matching anything), and an empty list still means no constraint.
  • The starting rules are read rule by rule, as gate.json is. A first start writes them to gate.json as given, since gate.json is read the same way, so its load names each problem until someone fixes it. Starting rules the gate refuses whole, such as ones that leave out a rule that could wake the agent under "skip", aren't written, and the gate starts with no rules, as a host without them does, where main wrote them and ran them from memory. A later start adds, as given, the starting rules whose names gate.json lacks. Where gate.json has no list to add them to, a list or a policies that isn't one and holds something, the start writes nothing over it and runs them under gate.json's default, unsaved, as main did once it had written them over the file, losing what it held; the startup entry says so, with who can give gate.json a list, and stays until gate.json changes and loads. Where gate.json has no rules of its own, with no list, an empty one or one holding only rules the gate leaves out, and, under its default, one of the starting rules that could wake the agent can't be put in force, left out, with no name, or kept out by a rule of gate.json's of its name that the gate leaves out, or they're refused whole and that default holds events back, gate.json isn't used at that start, which runs as one without it does: the starting rules under their own default, as main ran them once what it wrote over the file or into its list wouldn't load, or once it refused gate.json's rule, or no rules where the starting rules are refused whole. Both entries then name each such rule, a nameless one as "one with no name", and the way back, and a change through the gate is refused until gate.json changes, since it would put gate.json's rules in force in place of what the start runs. A rule gate.json holds keeps its name, whether the gate reads it or leaves it out, so the host's rule of that name doesn't replace the operator's: a probe of an operator's ambient rule, narrowed to Zulip with an empty tagsAny entry the gate drops, showed the recipe's ambient, which skips all ambient chat, otherwise silencing Discord's ambient chat under the operator's "always" default. A start whose gate.json can't be loaded runs them as read. They're read as gate.json is, so under a starting default of "skip" or "defer", a starting rule left out that could wake the agent means none of them is used, and the start runs gate.json's rules, or none. The reconcile never writes a gate.json it would then refuse: where adding the starting rules would leave out such a rule under gate.json's own "skip" or "defer" default, it adds none, and gate.json's own rules stay in force, with the startup entry saying why, or, where it has none, the start runs as one without it, as above.
  • What the start did is worded about the start (At start, … and In the host's starting rules, …) and goes in an entry of gate_status's errors that the pull request this builds on keeps apart from gate.json's load error. It says how the starting rules were read, any that couldn't be used and what the gate started with instead, and rules a later start couldn't add. It stays until the next start, since later writes and reloads don't change what the start did; configSource says what's in force now, and is "initial" while the starting rules are, where it stayed "default".
  • When the start's own write fails, whether it left gate.json as it was, part-written or written is read from gate.json after it. A failed first-start write runs the starting rules, unsaved. A reconcile whose write leaves gate.json unable to load runs, from memory, the rules it read plus the ones it was adding, since a failed write of ours shouldn't take rules out of force. An entry says what isn't saved, and what it holds that the gate reads past or leaves out, that gate.json may be part-written, and that a restart before it's fixed starts from the starting rules or from none, until gate.json is written, or a reload applies its text or finds gate.json holding the text the start was writing. A reconcile whose write failed at its open, leaving gate.json as it was, says the rules weren't put in force, and that whoever runs the host can fix what kept them from being written; where gate.json's own content kept them out, it says a start adds them once that's fixed.
  • wake_add_rule reads its own parameters the same way.
    • A null or "" parameter is left out, so a null behavior beside debounceMs, or a null or "" placement beside another, is no second one, where each was refused as one before; a lone "" placement already placed nothing. A call whose only behavior is null is refused as one with none, where it was saved as "always". A "" match is a match left out, and its note says the rule matches every event. A slip of a parameter is refused with "did you mean", and so is a field of match, match.filter, rateLimit or passiveSample given as a parameter of its own, which the rule used to be saved without: wake_add_rule has "tagsAny", a field of "match", which it doesn't take here, so nothing was saved. Put it in "match": {"tagsAny": ["chat:ambient"]}. Its parameters are …. Where the field belongs in one parameter, the refusal shows it there, as the call would give it, or names the parameter alone where a value is too long to show whole. Any other name is named in ignoredFields, and in any refusal of the call, within the refusal's own sentence where it's for a slip or a misplaced field.
    • A behavior other than "always", "defer", "skip" or an object: wake_add_rule: behavior must be "always", "defer" or "skip", and it's "{\"debounce\": 60000}", so nothing was saved. For a debounce, a rate limit or sampling, give debounceMs, rateLimit or passiveSample instead of behavior. The gate's own refusal would point at the object form, which XML mode can't send.
    • A placement: a position other than "append" or "prepend", or an insertBefore or insertAfter that isn't a string, used to be ignored, so the rule was appended. These are refused now. An anchor that names no rule was already refused, by the gate, in its own wording, which this change keeps.
    • Its description's example gives debounceMs: 60000, which works in both tool modes. The schema keeps behavior a string enum, since one type per parameter is what membrane's XML mode parses exactly.
  • wake_remove_rule's description says a pending debounce batch for the rule "is still delivered, held while your turn runs, as its timer would deliver it", where it said "delivered first": a removal's batch now goes the way a timer's does, held while an agent is inferring or the host is quiesced.
  • gate_status's entry for a gate.json the gate doesn't use says what's in force instead, so it reads on its own: … So what gate.json holds isn't in force, and the gate runs the rules of the last gate.json it used., or, after a start whose write left gate.json unable to load, the rules it read from gate.json at start and the ones it was adding, or the host's starting rules, or no rules: every event gets the default, "always". That covers one that can't be read or isn't JSON too, whose entry was the error alone.
  • The log line for a gate.json that won't load, which the pull request this builds on adds once per distinct error, says it "couldn't be used" here, since this change also refuses JSON it doesn't use: [gate] <path> couldn't be used (<reason>), so what it holds isn't in force.
  • A value a message quotes, when it's over 80 UTF-16 units, is cut to 77 and ..., and a skipped privileged-users entry, from the pull request this builds on, over 40 to 39 and …, each a unit shorter where the cut would split a surrogate pair.
  • The gate's existing messages are this change's where its reading is new, and otherwise keep their wording.

What a deployment will notice

A gate.json holding a rule the gate refused before now loads, with that rule left out and named, under "always", or where the rule skips or defers, or its tokens are a number at or under 0. A reload or a start applies its other rules, where a reload kept the rules the gate had, and a start ran the host's starting rules in its place, or none. Under "always", a rule after the one left out can now hold back an event the gate woke on before, when it refused the file and ran something else: a rule that skips or defers it, a rate limit or a sample that lets only some through, or a debounce that delays it. That's the case left where an event that woke the agent before can go quiet. Under "skip" or "defer", a gate.json that leaves out a rule that could wake the agent still isn't used.

A rule that skips or defers, with a problem the gate read past before, is read as before, holding back every event it then matched, and named: Policy "x": match.tagsAny is "chat:mention", where a list of non-empty strings is needed, such as ["chat:ambient"], so the rule is read without it. Read so, it skips every event it now matches, as earlier releases did. So the Problem section's { match: { tagsAny: "chat:ambient" }, behavior: "skip" } still skips every event that reaches it in gate.json, as it did, and now says so; wake_add_rule and addGatePolicy refuse it.

A starting rule the gate refused before is named at start. One used to make the gate run all of the starting rules as given, from memory, so these ran and woke the agent. Now, under a starting default of "always", such a rule is left out and the rest run; under "skip" or "defer", none of the starting rules is used, and the start runs gate.json's rules, or none, which hears every event. Another host's author can check their rules against them:

  • a rate_limit without refillIntervalMs, which woke on its first tokens events;
  • a debounce given as text, such as "60000", which debounced;
  • a passthrough that isn't a boolean, which ran as its truthiness, so "false" ran as true;
  • passthrough: true on "always", which woke on every event it matched;
  • a rule with no name, which ran as written, so one on "always" woke on every event it matched.

connectome-host's four recipes with a wake block hold none of them, and load with no notes, on a first start and on a later one.

A starting rule with a number out of range runs as read, where it ran as given: a debounce under 100 ms batches a burst whose events came further apart than it and closer than 100 ms into one wake, where each was its own; one over 2147483647 ms waits five minutes, where it ran after 1 ms; and a refillIntervalMs of 0 limits nothing, where the rule stopped after its one refill. connectome-host's recipes hold none of them.

A batch whose delivery throws, from its timer or once an agent's turn ends, is logged and dropped, where it was thrown uncaught, which stops a host with no handler for that. addMessage throws so with no agent configured.

A change to a gate.json with no rule list refuses, a removal included. A removal there answered that no rule had the name, so a host's removeGatePolicy, such as connectome-host's channel-mode's, now meets the same refusal as its addGatePolicy, naming the fix, where an addition wrote over the file. Beside a slip of policies, rules under another name or a rule's fields at the top, a change adds a policies list and keeps them, where an addition used to write the rules in force over the file and drop them.

A change over a gate.json the gate doesn't use is refused unless it mends it. Such a file, JSON the gate refuses whole, used to be written over with the rules in force, and the pull request this builds on kept a copy of it first. Now it stays as its operator wrote it, and the refusal names what keeps it out and what would bring it into force; replacing or removing that rule by its name does, where the file is left rules in force, since otherwise its default would hold back every event, and one with no rule list, whatever its default, refuses as above. A file kept out by two such rules, or by a rule with no name, can't be mended by one change, since each change touches the rules of one name, so there the refusal says gate.json has to be fixed by an edit of its own. A gate.json that could be read but isn't JSON, or can't be read at all, refuses a change too, so no change writes over what gate.json holds; 0.26.0 wrote the rules in force over such a file, and the pull request this builds on kept a copy of one that isn't JSON before it did. So a host that restarts before such a file is fixed runs its starting rules, as at any start over a gate.json that won't load, where 0.26.0's write had left a file that loads, without the operator's edit.

The starting rules are read rule by rule, unless, under a "skip" or "defer" default, one that could wake the agent is left out. A first start writes them as given, as before, unless the gate refuses them whole, when it writes none, and gate.json then names, at every load, a starting rule's problem that the startup entry names too; a later start adds the ones gate.json lacks by name. Where gate.json has no list to add them to, a list or a policies that isn't one and holds something, a later start keeps it as written and runs the ones it lacks beside it, unsaved, under its default, where it wrote them over the file, losing what it held; where one of them left out could wake the agent under that default, gate.json isn't used at that start, which runs as one without it does, and the same over a gate.json with a list but no rules of its own, where it wrote them into the list and left it unable to load.

A number outside the gate's range is read as above, with a note: gate.json loaded. Policy "fast": behavior.debounce is 50 ms, under the 100 ms minimum, so it's read as 100 ms. The gate refused such a gate.json whole before. A first start writes a starting rule's value as given, and gate.json's load reads it the same way.

A rule a slip already saved in a valid form loads as it was saved: "always" saved from a typo, or match: {} saved from a dropped field, can't be told from a rule written that way on purpose. Rules with an empty match, or that wake on "always", are the ones to look at; a broad rule placed early also shows in the shadow lint.

A gate.json annotated with keys the gate has never read, such as "comment", still loads, and a null or "" field loads as left out. A change now keeps them, and every rule it doesn't change, as written, where it used to write the rules as the gate held them.

What each part is for

This change is for whoever writes a wake rule, an agent through wake_add_rule or an operator in gate.json: a rule that can't do what it says is named, and the gate never wakes the agent on less than before because of how it reads a file it used to load. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.

  • A reader for each level that names each problem (gate.json, the rule, match, filter, behavior, rate_limit, passive_sample), each problem with how it's read. The simpler shape is main's validatePolicy, which rewrote or dropped what it couldn't use without a word: the Problem section's cases.
  • Slips, through closestNames and isNearMiss (src/closest-names.ts, from fix: tool presentation and code_execution errors say what happened and what helps #413). Without them a misspelt field is only an unknown name, and nothing says that pasthrough: true left a governor consuming the events it should let through. A name holding a field's whole name, where its object doesn't give that field, is a slip of it too: one condition beside isNearMiss, where every name an object gives is seen (fieldNames). Without it, matchTags in place of match, or matches for wake_add_rule's match, saves a rule that matches every event. Beside its field, such a name is an extra that explains it, and stays a note. Refusing every name the gate doesn't read is the simpler shape, and it would refuse those extras too. gate.json's own top keeps the plain reading, through one argument, since such a name there can be a label of the file, such as policiesUpdated.
  • A field read at another level, named with where it's read, from one map of each level's fields. Taken as an unknown name, tagsAny beside name was said to do nothing while the rule matched every event.
  • wake_add_rule and addGatePolicy refuse any problem, in one refusal that names them all. Saving with a note would store a rule whose author is there to fix it.
  • In gate.json and the starting rules, a rule with a problem is read as main read it, and left out only where main refused it, a rule that skips or defers included. Refusing the file, or leaving out every rule with a problem, are the simpler shapes. Leaving out the rules that skip or defer would silence events main woke on, wherever a rate limit, a sample or a debounce came after them.
  • Under "skip" or "defer", a file isn't used where it would leave out a rule that could wake the agent, from a check over the rules left out. Loaded, such a file holds back what that rule was written to wake on. Any other file main loaded loads as main read it, named: leaving it out of force, as for a slip of policies or rules parked under another name, would run the host's starting rules at a start, or wake the agent on every event, in place of a file its operator may have kept quiet on purpose. A check of a value that holds nothing (holdsNothing) reads a policies holding nothing as no rules, as [] is read.
  • A number out of range is read with a note, a debounce at its nearest bound and a refill interval of 0 as refilling at once, by one reader for gate.json and the starting rules. Refusing the file for it, as main did, took every rule in the file out of force for one value, and running the starting rules' values as given would keep a second reading for them.
  • A change edits gate.json's own list by name, writing back every rule it doesn't change as written (rulesForChange, writeConfig). Writing the rules in force, as main did, drops comments, the rules the gate leaves out and their problems. The write clears the state of the rules it takes out of force or changes through the reload's own cleanup, one helper both call (clearStateFor); without it, a rate limit a write left out kept its spent bucket, which a reload that restored the rule went on using. Each pending batch then goes out through the timer's own path (fireDebounce), one call per rule, so it's held while an agent's turn runs, as its timer would hold it, rather than landing mid-turn when the change comes from the agent's own wake_add_rule. One whose delivery throws is logged and dropped, not thrown: thrown after the write, it told the caller that a change it had saved and put in force had failed. The timer's path catches its own delivery, and so does the flush of what was held for an agent's turn, where a throw went uncaught. A name's state is kept by the behaviors of its rules, in order, since rules that share a name share it.
  • A change over a file with no rule list, one the gate can't read or that isn't JSON, or one it doesn't use, is refused, saying what would bring the file into force where one change can. Writing the rules in force over it, as main did, drops the operator's rules.
  • The start's notices (describeStart, describeUnsaved) keep what the start did apart from gate.json's load error, which main's next load cleared, and the reconcile writes nothing it would then refuse. Over a gate.json with no rule list, the start runs the starting rules it lacks beside it, unsaved, with configSource "initial", which keeps its entry through a reload that finds gate.json unchanged, and gate.json's own entry says they run beside it: writing nothing and running none would leave out of force rules main ran there, once it had written them over the file. Where gate.json has no rules of its own, with no list or a list with none, and one of them that could wake the agent under its default can't be put in force, left out, with no name, which a start can't add by name, or kept out by a rule of gate.json's of its name that the gate leaves out, or they're refused whole and that default holds events back, seedOrReconcile gives start-without, from one check before the reconcile over the starting rules as read (StartingRules.rules, which also give the rules run beside a gate.json with no rule list without reading them again), with outOfForce wording each such rule and what would put it in force for both entries, and gate.json isn't used at that start, which runs as one without it does: running none would hold back every event, where main, its write unable to load or gate.json refused for its rule, ran them from memory under their own default. A change is refused while gate.json holds that start's text (NOT_USED_REFUSAL), since it would put gate.json's rules in force in place of what the start runs, and once gate.json is removed, a change builds on the rules in force rather than on gate.json's list.
  • gate_status's entry says what's in force while gate.json isn't used, with one flag. The bare error leaves its reader to guess what runs.
  • checkToolInput reads wake_add_rule's own parameters the same way, from its schema, so a slip of a parameter isn't dropped. Its example of where a misplaced field goes, a few lines, is shown only where the field has one home and its value can be shown whole: the refusal is what an agent retries from, and the example saves a guess at the parameter's shape.
  • ignoredFields and onIgnoredFields name a field the gate reads nowhere, in wake_add_rule's result and to addGatePolicy's caller, and the change is saved. Refusing such a field would refuse a "comment", and dropping it unnamed leaves its caller thinking it did something.
  • writeConfig's message for a failed write, a few lines, says whether gate.json was changed and that the rules in force stay, through beforeWriting, which the start's write already used: a failure at its open leaves gate.json as it was, and one after may leave it holding the whole change, which the next reload reads, part of it or nothing. Without it, a change's failed write reaches the agent as Node's error with no word on gate.json or on what's in force, and the one who can fix it is the host's operator.
  • The refusal of a change that would leave a gate.json the gate doesn't use with no rules in force, one check beside writeConfig's check that the result loads, and the remedy's offer of a removal only where it leaves rules. Without it, the removal the gate's own refusal offers would put in force a file that holds back every event.
  • wake_remove_rule's description, one sentence, says a removed rule's pending batch is held while the agent's turn runs, where it said the batch is delivered first. Changing it rewrites every agent's cached tool list once, at the release that ships it, beside wake_add_rule's example; left as it was, it would tell an agent something false about a batch it's waiting on.
  • When a fix takes effect, a few words in the start's entries and the refusals: a fix to the starting rules puts gate.json in use at the next start, which reads them (outOfForce's nextStart), and a restart after a partway write starts from the starting rules. Without them, an operator who fixes the starting rules sees nothing change until a restart and can't tell why.
  • removePolicy's check of the name, one line. Without it, a call from JavaScript with no name would remove every rule gate.json holds with no name.
  • notJson, one field of readConfigFile's result, tells a gate.json that couldn't be read from one that isn't JSON, for the two refusals' remedies, where the copy's bytes did.
  • rulesRun says what the gate runs, from the rules in force and where they came from, for gate_status's entry for a gate.json it doesn't use and for each refusal of a change. Without it, an agent whose change is refused can't tell what wakes it meanwhile.
  • A refusal's note on a match that narrows nothing gives the reading earlier releases made as the reason the problems before it matter, one line in notesOf. Without it, a wrong type in a match reads as a nitpick, where saved it woke on every event.
  • The tests: test/event-gate-strict-rules.test.ts (2603 lines) and changes in three other files pin each reading, write and refusal, and what each wakes the agent on.

Cut along the way: the check of which fields of a rule, at gate.json's top or in a list's objects, said a rule was meant, with its list of names that shape nothing on their own, which took a file main loaded under "skip" or "defer" out of force; a copy of gate.json kept as written beside the rules in force, in place of edits by name, at about 150 lines against an estimated 50; the copy the pull request this builds on keeps of a gate.json that isn't JSON, which, once a change edits gate.json's own list, served that one file, with a file kind, four public additions and its tests, where a refusal keeps what the file holds without them; a remedy that named a change by a rule's name, false where two rules share it, now the fact of what such a change does; and the no-list refusal's example, since each form of it, copied as written, did harm. Not added: refusing a field the gate doesn't read, which would have taken a gate.json with a "comment" out of force.

Tests

test/event-gate-strict-rules.test.ts (new, 43 tests). Most are unit tests of EventGate with temporary directories, and one runs wake_add_rule as model-issued calls and checks the results the model is sent. In test/event-gate.test.ts, the two debounce-bounds tests, which pinned a gate.json holding an out-of-range debounce as refused whole, now pin it read at the bound, and two hot-reload tests no longer wait for an mtime the gate doesn't read, as the rate-limit tests' helper no longer does. In test/event-gate-rate-limit.test.ts, a starting rule's fractional every is now read as its next whole number, a rate limit of no tokens is left out, one whose refill interval is negative is read as a bucket that never refills, with five events a minute apart waking the agent twice, and one whose interval is 0 as refilling at once, with five events at once waking it on each, the probe saying the same at each step. In test/event-gate-unreadable-files.test.ts, a gate.json whose default isn't one of the three, which the gate refuses whole, is now neither written over nor kept: a change and a removal are refused, naming the default, the change's refusal adding that gate.json has to be fixed by an edit of its own, the rules in force stay, and the log line says gate.json "couldn't be used", once over three reads. A gate.json that won't load has an entry that says what's in force instead, and one that can't be read refuses a change, saying what runs and that whoever runs the host can make it readable. The copy's tests turn round: a change, and a removal of any name, over a gate.json that isn't JSON, saved as Latin-1 or UTF-16 too, is refused, naming the parse error and what runs, and the file stays byte for byte, with nothing beside it; a removal refused there keeps the rule's pending batch; wake_add_rule and wake_remove_rule give the agent that refusal; and a gate.json that's JSON again takes the change. The tests of the copy's mode, name, failures and callback go with it, and the unprintable thrown value one of them pinned is pinned through onIgnoredFields. A skipped privileged-users entry is quoted cut at 39 units, a unit shorter where that would split a surrogate pair.

  • Refused by addPolicy: each behavior case, a NaN and a text debounce, each number out of range, two kinds or three in one behavior, with each other kind's problems named after the first's, in the order they're read, each match field's wrong type or bad entry, an invalid regular expression, resets and passthrough of the wrong kind, each field slip at each level, a name holding the whole name of a field the rule doesn't give, at a rule's top and in its match, behavior and rate limit, naming both for one holding two the rule gives neither of, and only the other where it gives one, a field put at another level (a match field and a behavior beside name, a filter's in match and a sample's in a rate limit), a rule with no name or that isn't an object, and a rule with three problems, named together with its note. Nothing is written for any of them. A value quoted past 80 units is cut without splitting a surrogate pair.
  • Read in gate.json, under a "skip" default, each with its note and the events it wakes on: a typo, the object form as text, a slip of behavior, a match that isn't an object, fields of the wrong type, list entries, an unreadable filter and resets, two kinds, a keyBy slip and a wrong-type keyBy, a misspelled passthrough on a sampler, and an empty scope entry on a rule that skips. Rules that skip or defer with a dropped field, a slip or a bad scope entry, read as before with a mention held back where it was, and one ahead of a rate limit of 1, which spends its token only on what reaches it, so a Discord post, a Zulip post and a Discord post wake the agent on the second alone, as before.
  • A field at another level, in gate.json: a match field beside name on a rule that skips, a filter's in match and beside name on a rule that skips, a rate limit's, a behavior's and a sample's beside name, gate.json's default in a rule and a rule's name at the top, each in force as before and named with where it's read, with a mention held back by "quiet", which matches every event, as before; and in the starting rules, a field of the starting config.
  • Left out of gate.json, with the rest loading and a mention waking the agent: each class the gate refused before, under "always", and under "skip" the ones that wake on nothing, a rule with no name that skips, one that defers with a passthrough, and a rate limit with tokens of 0 or of -2, with the note saying why.
  • Not used, under "skip" or "defer": a gate.json that leaves out a mention rule the gate refused before, beside a rule that still wakes, at a start with starting rules, which then run, and without, which runs the default; at a reload, which keeps the rules in force; under "defer", naming both rules left out; as the host's starting config, with no rules started; and a later start that won't add starting rules that would leave gate.json unused, and says why.
  • gate.json itself: a list, a policies that isn't a list, "policies": {}, slips of policies and default, a slip holding a rule beside "policies": [], and a list of rules under rules, under "always", read with their notes; a bad default and a non-object, not used, each entry saying the gate runs no rules, and, after a reload keeps the rules in force, the last gate.json's; a list beside policies under another name, and a rule's fields at the top under "always" or beside a policies holding a rule, read as written and named; under "skip" or "defer", a slip of policies holding a rule or nothing, rules under policy, notes, disabled or history, a rule's fields, a match field, a rate limit's fields, a keyBy beside tokens, a filter's type beside its pattern, "passthrough": false, "tokens": 0, "rate_limit": {} or "behavior": [] at the top, labels named name or source or neither, a list of strings, "policy": [], a slip holding a rule beside "policies": {}, rules under rules or a rule's fields beside "policies": [], and "policies": {"a": []} or {"a": null, "b": ""}, each loaded as written, holding back a mention, named, and kept by a change that adds a policies list beside it, in which the rule it adds wakes the agent; and a policies that isn't a list and holds something, loaded the same way.
  • gate_status's entry for a gate.json the gate doesn't use: one that isn't JSON says the gate runs the rules of the last gate.json it used, until the text in force comes back or new text loads; a change over it is refused, and the entry stays.
  • Writes: a change keeps every rule it doesn't change as written, gate.json's own fields included, replaces or anchors on a rule the gate leaves out by its name, and removes one by its name; its notes stay named after it; once gate.json is removed, a change and a removal edit the text in force the same way; over a gate.json that can't be read, a directory at its path included, or isn't JSON, saved as Latin-1 or UTF-16 too, a change and a removal of any name are refused before an anchor is looked for, naming why and what runs, and the file stays byte for byte; a write that fails at its directory, its open, partway or at its close says which, with the filesystem's error as its cause, and the next reload reads a whole change a failed close left; a change through a symlinked gate.json keeps the link and its file's mode; a removal by a name that isn't a non-empty string finds no rule; over JSON the gate doesn't use, a change that would leave it no rules in force is refused, at a later start, at a reload, where it keeps only a rule the gate leaves out, and where two rules share the name, and a replacement then brings it into force; a change that doesn't mend it, an addition or a removal, is refused, naming what keeps it out, and leaves the file as written with nothing kept, a partial mend is refused naming what's left, each refusal names what would bring the file into force, the one rule by name, or, where two rules, one with no name or a bad default keep it out, that gate.json has to be fixed by an edit of its own, and where the rule shares its name with a sound one, says what a change by that name does, so a mended replacement taking the sound rule is refused again; replacing or removing the rule by name brings the file into force with its own fields and default; a change, an addition or a removal, refuses, writing nothing, where gate.json is a list, its policies isn't one and holds something, under "always" and "skip", or it isn't an object, a removal of a name it may not hold included; beside a slip of policies holding a rule, with no policies or beside {} or [], rules under policy or rules, a rule's fields at the top, with no policies or beside [], and a "source" label there, a change adds a policies list and keeps them, and a removal of a name only they hold removes nothing; a write that leaves a spent rate limit out of force, and one that gives it more tokens, each start it on a fresh bucket, and the one restored after the first with no counts, as one a reload leaves out and restores does; a rule a write removes has its pending debounce batch delivered, with the rule out of force, and one a write changes while an agent's turn runs held until the turn ends; a delivery that throws is logged and dropped, and the change returns as made, saved and in force, with the state of every rule the write took out cleared before any batch is sent and the batch of another rule after it sent, and a reload whose delivery throws puts its new rules in force; a batch whose delivery throws dropped and logged from its timer, with no delivered trace, once an agent's turn ends, of two events, not sent at the next, and once a quiesce ends, of one; rules that share a name keeping a pending batch through a write that changes neither, and sending it once a write gives the first the second's behavior or a reload swaps their behaviors; an onIgnoredFields that throws logged once the change is traced, the change answering as made; and wake_add_rule keeps a rule with a problem it didn't change.
  • Loaded and named, and left out: unknown fields at three levels of a loaded file, a match's channelNote beside channel, a rule's matchTags with no match read as match left out, and defaultBehavior and policiesUpdated at the top, so a file under "skip" with policiesUpdated and no rules is used, quiet, and a "__proto__" in a match, which narrows nothing; a field left out by addPolicy, told to onIgnoredFields, or logged without it; null and "" fields through addPolicy and in a loaded gate.json, a "" field named like one every object inherits, such as "constructor", included, with a note for each "" that says what a "" behavior, match, default, keyBy or policies, under "always" and "skip", leaves, and a match left with nothing that narrows it, its entries all dropped or its only other field read nowhere, said to match every event, where an empty list alone or an empty match says nothing; a filter of a type the gate can't read refused beside its "" pattern; the note stays with the text in force.
  • Every field the gate writes, it reads back: rules using every field, written by addPolicy, load again with no errors and are written back unchanged. This one is a control and passes on the parent commit too.
  • The starting rules: written as given, and read rule by rule: each read or left out and named, at start and at gate.json's load, with the user's input still waking the agent; loaded with no rules under their default where their policies isn't a list, as they ran, and refused whole only where, under "skip", they leave out a rule that could wake the agent; and a starting config that's a list, read as no rules. A first start that can't create gate.json runs them, says they aren't saved, and stops saying so once a reload applies gate.json, and one whose write fails partway says gate.json may be part-written. A later start adds the rules gate.json lacks as given, beside its own rules as written, or in place of a "policies": {}; doesn't replace a rule gate.json holds, read past a problem or left out, through a change and the next start, or a change once gate.json is gone and the next start; runs the ones a gate.json that's a list, or whose policies isn't one and holds something, lacks beside it, under its default and unsaved, with configSource "initial", gate.json's entry and the start's saying so through a reload that finds gate.json unchanged or changed to text that won't load and after a change refused there, a listing naming them, and no longer once it changes and loads, a rule the starting rules leave out named so and not as one that runs, gate.json not used where one of them left out could wake the agent under that default, the starting rules then running under their own default, or no rules where they're refused whole under their own "skip", while beside a gate.json with a list of its own its rules stay, and none where every one is left out; the same beside a gate.json with an empty list, a policies left out, "" or {}, or only a rule the gate leaves out, each with its entries, a starting rule with no name, and one whose name gate.json holds in a rule the gate leaves out, which counts unless it skips, an addition and a removal refused until gate.json changes, an unchanged reload, and the rules in force written once gate.json is removed, and beside one with a rule of its own, its rules kept; and adds them beside rules under a name it doesn't read. A start over a gate.json that doesn't load runs them, with configSource "initial".
  • Numbers: a 50, a 500000 and a 0 ms debounce, a sample step of 0 and of 2.5, and a refill interval of 0, read as refilling at once, in the starting rules (written as given) and in a gate.json the gate seeded before, each with its note and log line; addPolicy refuses each; a rule with a number and another problem is read past both.
  • What the start did: a reconcile that fails at its open stays named through a write of another rule; what the start said about a starting rule stays after a reload applies new text. A reconcile whose write fails partway, in writing or at its close, runs what it read and what it was adding, says so in gate.json's own entry, and, in the start's, names gate.json as possibly part-written, with what the rules hold that the gate reads past or leaves out, the rules it was adding among them, until a change writes what it was writing, the rules gate.json held as written included, or a reload finds gate.json holding that text; one whose write lands and then fails at close says nothing of adding them, a rule the gate leaves out included.
  • wake_add_rule: the object form as text, a typo, a number and a list for behavior; a position, an insertBefore and an insertAfter of the wrong kind; an anchor no rule has; a slip of a parameter, matches with no match among them; a field of match, of match.filter and of rateLimit given as a parameter of its own, refused naming where it goes and, where it belongs in one parameter, showing it there, two fields together included, or naming the parameter alone where a value is too long to show whole, and one inside match that the gate refuses; a rule with two problems; a refusal of a slip, of the tool's own and of the gate's, each naming a parameter it doesn't take beside them, after a stop where the gate's text has none; nothing saved for any of them, as gate.json read back after them shows. A null behavior beside debounceMs, a null position and a "" anchor left out, a "" match named as matching every event, an unknown parameter named, and matchNote beside match named and saved without. ignoredFields for a match field it left out, the description's two examples, run as the calls they show, and a change that keeps a rule with a problem as written.
  • A gate.json saved with a UTF-8 byte order mark, which main now loads, reads as the same file without one: at a later start beside it, against a twin without the mark; once it's gone, where a change builds on its text and keeps a rule added by hand; and through wake_add_rule with the file there, which keeps that rule and keeps no copy.

Red first: on the src this change builds on, #371's head (29afb436), with this change's four test files, 57 of their 161 tests fail: 40 of the 43 in event-gate-strict-rules.test.ts, with the round-trip control passing, and so do the test of a pending batch delivered when a write removes its rule, which #371's removal delivers too, with its rule out of force, and the test of a removal by a name that isn't a non-empty string, which finds no rule there either; the 3 changed validation tests of event-gate-rate-limit.test.ts; the 2 debounce-bounds tests of event-gate.test.ts; and 12 of the 29 in event-gate-unreadable-files.test.ts: the start's entry, the gate.json refused whole that's no longer written over, the log line that now says "couldn't be used", the skipped privileged-users entry quoted without splitting a surrogate pair, the entry of a gate.json that won't load, which now says what's in force, the refusal where gate.json can't be read, which now says what runs, a directory at gate.json's path, which this change refuses as a file it can't read, and the five of a gate.json that isn't JSON, which #371 copies and then writes over, and this change refuses, one of them before an anchor or a name is looked for. Their typecheck fails only where four of these tests pass addPolicy the onIgnoredFields option this change adds, and the build emits them anyway. Each piece: 384 pieces were each undone on their own, and every one fails at least one test of the gate test files they run: 186 at this pull request's first head, 5bbde6fb, but for a comma in a doc comment; 20 of the commit answering Greptile's review, at 80503bef, with the one of the 186 whose code it moved; 7 of the two commits after it, at f97f8fb6; 25 of the commit answering the maintainer's review, at dce014e9, 8 of them earlier pieces pointed at the code it rewrote, with the 17 earlier ones whose only catching test it changed; 18 of the commit after it, at 85e616de, 5 of them earlier pieces pointed at the code it rewrote, with the 4 earlier ones whose only catching test it changed; 15 of the commit after that, at 065a6f8c, 5 of them earlier pieces pointed at the code it rewrote, with the 6 earlier ones whose only catching test it changed; 3 of the next, at d6af7ad5, one of them an earlier piece pointed at the code it rewrote, with the 14 earlier ones whose only catching test it changed; 15 of the one after it, at 5fe50b2f, 4 of them earlier pieces pointed at the code it rewrote, with the 12 earlier ones whose only catching test it changed; 1 of the one after that, at f01b68eb; 20 of the next, at 5bc9e522, 8 of them earlier pieces pointed at the code it rewrote, with the 23 earlier ones whose only catching test it changed; 15 of the one after it, at b7f50260, 5 of them earlier pieces pointed at the code it rewrote, with the 30 earlier ones whose only catching test it changed; 5 of the one after it, at 5009d968, with the 43 earlier ones whose only catching test it changed; and 4 of the one after it, at 79338031, each place gate.json's or the privileged-users file's text is parsed put back to JSON.parse in place of main's parseGateJson. The merge after it brings in #371's head again and adds none. And 12 of the next, at bb90461d, which takes #371's copy out, with 6 earlier pieces pointed at the code it rewrote: each refusal over a gate.json a change can't edit, what it says runs, a refusal's note on a match that narrows nothing, and where wake_add_rule shows a misplaced field; 11 of the one after it, at 1ee671bf, one of them an earlier piece pointed at the code it rewrote; 14 of the next, at 74eb735b, 2 of them earlier pieces pointed at the code it rewrote, with 4 more such earlier pieces and the 126 earlier pieces whose only catching tests the last three commits changed or took out, of which 2 failed none; 9 of the one after that, at c45f0437, 3 of them earlier pieces pointed at the code it rewrote, with the 73 earlier pieces whose only catching tests it changed or that no test caught at their last run, those 2 among them, which a test it extends now catches; and 4 of the last, at this head, 2 of them earlier pieces pointed at the code it rewrote, with the 54 earlier pieces whose only catching tests it changed. 111 of the earlier ones undo code this head has since rewritten or taken out, #371's copy and the check of which fields said a rule was meant among it. They cover each problem named, each reading and each rule left out, which rules can't wake the agent and the files that therefore aren't used, gate.json's own fields read as main read them, each named, a slip of policies with what the file's default then does, the writes that edit gate.json's own list by name, beside what the gate doesn't read and over JSON it doesn't use and over the text in force once gate.json is gone too, what a refused change says, a change refused over a gate.json that can't be read or isn't JSON and what it says runs, the starting rules' seeding and reconcile, the reconcile's refusal to write a file it would then refuse, the startup's entries and what clears them, the write outcomes, the note of the text in force, a removal's refusal where gate.json has no rule list, and the refusal there giving no example, the remedy a refusal names, and that it names none for two rules, for a rule with no name or for a name two rules share, the notes for a file with no rules, an emptied match and a keyBy left out, a rate limit with negative tokens, a reload that clears the start's note that the starting rules aren't saved once gate.json holds the text in force, a seed that fails partway, the log line, the cuts at 80 and 40 units, each of wake_add_rule's checks, with where a misplaced field goes, a field the gate reads at another level, named with where it's read and how the rule then reads, at gate.json's top and in the starting config, and refused by wake_add_rule from its own schema, a policies holding nothing read as no rules, the entry of a gate.json not used saying what's in force, the rules a start's own write left unsaved named there, with what they hold that the gate reads past or leaves out, the note of a match that narrows nothing, which a refusal gives as the reason for its problems, and also a "__proto__" kept a field, the clearing of state on a write, left out or changed, with counts and a pending batch, kept by a name's rules' behaviors in order, the problems of a behavior's other kinds and their order, the parameters a refusal names and the stop before them, a "" field's note read from its table's own fields, a write's clearing, after its rules are in force, against the rules that were, each pending batch sent last, once every rule is cleared, on its own, through the timer's path, one that can't be delivered logged and dropped rather than thrown, from a write, a timer and what an agent's turn or a quiesce held, a refill interval of 0 refilling at once, in the bucket, in the probe and in its note, an onIgnoredFields called contained after the trace, the starting rules a gate.json with no rule list lacks run beside it, with configSource "initial", gate.json's note saying so, after a change refused there too, the start's entry naming only the rules it runs, a listing naming them, and when that entry goes, gate.json not used where one of them that could wake the agent can't be put in force, left out, with no name or kept out by name, or they're refused whole, over no list or a list with no rules of its own, a change refused there and built on the rules in force once gate.json is gone, and none run where every one is left out, the remedy in the entry for starting rules a start couldn't add, and a dropped batch's size in its log line, with no delivered trace and nothing held sent again; and a name holding a field's whole name taken as a slip of it only where its object doesn't give that field, whatever its case, and not at gate.json's top.

Against main: a colleague's six probes ran main's gate, at e6703634, beside this change's, at this head. One loads 142 gate.json configurations, each varied one field at a time, and runs a battery of events through each: every file main loaded, this change loads, and it finds no event main woke on that this change doesn't, where main loaded the file, and 139 where main refused the file and ran something else in its place. One starts with connectome-host's four recipes and other starting rules, on a first and a later start, over files an operator changed: the recipes come through with the same rules and the same decisions, and no event main woke on goes quiet. Two follow the ambient case above. After a change and a restart, Discord's ambient chat wakes the agent on both. After a change over a gate.json that won't load, mid-edit, this change refuses the change and leaves the file as written, so the restart, finding it still unloadable, runs the host's starting rules, which hold Discord's ambient chat back; main, which had written the rules in force over the operator's edit, woke the agent on it. The fifth runs files whose every waking rule is left out, under "skip" and under "defer", with starting rules and without: every event wakes the agent on both. The sixth sends one interleaved stream of events through each gate over the 144 configurations described above, and every event's decision is the same on both. A seventh, ours, loads 38 files in each gate, with starting rules and without, each under the default its case gives, "skip", "defer" or "always", and makes 14 changes through each. The files loaded hold: a match field beside name; a filter's field in match; rules under policy, under rules beside "policies": [], and under a slip "polices" beside "policies": {} or []; a rule's fields at the top, alone or beside "policies": [], and a match field or a rate limit's fields there; labels at the top named name, type, keyBy or source, a "comment", and notes lists; a filter's type beside its pattern at the top; fields holding nothing there, an empty tagsAny or match and "rate_limit": {}, and "tagsAny": [""] and "behavior": []; and "policies": {} and a slip "polices" holding [] or {}. Both decide alike on every event of every file. main saved a match field beside name, through addPolicy and wake_add_rule, as a rule with no match, and a filter's field in match without it; this change refuses both. An addition over a list under another name, a rule's fields at the top, a label, a field holding nothing or a slip of policies, which main wrote as the rules in force, dropping the rest of the file, keeps what the file held beside the rule it adds, and over "policies": {} both write the list in its place.

The suite: GitHub's CI runs it at this head. At this head, each of its 292 test files, run on its own on Node 24.22.1, passes: 6784 tests, 6740 passing and 44 skipped, all 44 in two of main's test files that this change doesn't touch. Our run of CI's test step passes 6740 of 6784 on Node 24.22.1, with 44 skipped, and 6739 of 6784 on Node 20.20.2, with 45 skipped, and its unforced step 132 of 132 on each Node.

Not verified

  • Windows and macOS. Our runs were on Linux, Node 24.22.1, with our run of CI's test step on 20.20.2 too. The tests that need file permissions are skipped on Windows and as root.
  • A disk that fills during a write. The partway and close failures are simulated in the test by replacing writeFileSync, not by a full disk.
  • Deployed gate.json files. We can't see which carry a slip or a rule now left out, or a starting rule with a number out of range. connectome-host's recipes carry none of them.

Out of scope

  • The wording of the gate's existing messages, where this change's reading isn't new.
  • Duplicate rule names, which nothing refuses, before this change or after, and which addPolicy and removePolicy resolve differently.
  • A gate.json that isn't UTF-8 but parses once decoded, such as a Latin-1 file whose non-ASCII bytes sit in its strings: it loads with each such byte read as U+FFFD, and a change or a later start's reconcile writes it back as UTF-8 with those characters, as 0.26.0 does.

Stacking

This builds on #371, and its diff includes #371's six commits and its merges of main until it merges. It takes out #371's copy of a gate.json that isn't JSON, so #371 merged without it would ship a copy, and four public additions reporting it, that this then takes out.

Review

Our review: internal reviews of the change before publication, whose findings this head answers. Gordon-2414 read it across its rounds and approved the code it opened with, but for a comma he asked for in a doc comment. Besides reading, he ran main's gate beside this change's on gate.json files of his own, the six probes the Tests section describes among them. His runs showed that refusing every file with a problem refused files main loaded, that leaving out a rule that skips or defers silenced events main woke on behind a rate limit or a sample, that a label named like a field took a quiet file out of force, and that an empty list at the top did too. Greta-2425 read it for what an operator meets. She found a field at the wrong level whose note said it did nothing while the rule around it matched every event, and a list of rules under "policy" that loaded under "skip" holding back every event. Greptile's review here then found four defects in this change's code: a "__proto__" field set a copy's prototype, a write kept the state of the rules it took out of force, a refusal named only the kinds a behavior held, and wake_add_rule named a parameter it doesn't take only when it saved the rule. Its other two were #371's, answered there and in that change's fragment, which this one edits. 80503bef answers the four, with the findings of two more internal reviews of it, and Gordon read it and approved it, with probes of his own of what loads beside main's: a "__proto__" in a match, a rule, gate.json's top, a behavior and a timing kind, and behaviors of two kinds or three. He found that a delivery that threw kept the batches after it from being sent, and this head sends each on its own. Gordon also read the two commits after it and approved them. With a probe of his own, twelve gate.json files, each with a name holding a field's whole name in that field's place or beside it, load with the same rules in force and wake on the same events as at 80503bef, and only their notes change. Anarchid's review of f97f8fb6 then found that a file main loaded under "skip" or "defer", its rules parked under another name or a rule's field at its top, went out of force and, with no starting rules, woke the agent on every event; that a batch held for an agent's turn whose delivery threw went uncaught; that rules sharing a name lost their state at every write; and that out-of-range starting values didn't run as they had. dce014e9 answers the four, after a merge of #371's head and main, and the eight commits after it answer six more internal review runs and two reads by a colleague. The merge after them brings in #371's head, which merges main, and the commit after it parses gate.json's kept text through main's parseGateJson too. The merge after that brings in #371's head again, which merges main at 091fa493, and the commits after it refuse a change over a gate.json that isn't JSON in place of #371's copy, and answer the reads below. Gordon read the merge and the commits after it through 79338031, with probes of his own against main, and approved the merge and the four commits after it, the three after those, and the two after those: the merge of #371's head and the commit after it. Reading the two between the first four and the next three, he found that a gate.json with a list but no rules of its own still held back every event under "skip", which the next three answer. His probe at 79338031 wakes the agent on every event main woke on beside a gate.json with no rule list, an empty one or none, under "skip", where the starting rules leave one out, hold one with no name, are kept out by gate.json's rule of their name, or are refused whole; it refuses a change there with gate.json's bytes as they were, and keeps a gate.json's own rule in force. With a byte order mark before every gate.json it writes, its output is the same, line for line. Pippa-2697 read the merge after them, which brings in #371's head again, and approved it. Gladys-2413's pass over what each part of the two pull requests is for found that, once a change edits gate.json's own list, #371's copy served one file, and the owner took it out here; Crispin-2406 read the refusals as the agent who meets them would, which gave four of their changes. Gordon read the commit that takes the copy out and each one after it, and approved each: the refusal of a gate.json that isn't JSON before an anchor or a name is looked for, the bytes only the copy needed, the refusal's wording "no rules the gate reads" and, where two rules share the name, the remedy's offer of a removal only where it leaves rules were his points. Four internal review runs read those commits, and each issue they raised was judged; this head fixes those judged worth fixing. Following one of them, a probe showed that removing the only rule of a gate.json the gate doesn't use, as the remedy offered, put a file in force that held back every event, and the owner chose to refuse that change.

We found the problem while rewording the gate's messages for Phoebe-2419's sweep of agent-framework's agent-facing errors. Ada-1017 made the design calls. Ogden-2688 found that a name holding a field's whole name went unread in that field's place, and Barnaby-2576 proposed reading it as a slip there. It carries Alfred-2570's commit adding closestNames, which #413 carries too, and builds on #371.

Fixes #390: a change clears the state of a rule it leaves out or gives another behavior, as a reload does, and a pending batch it sends is held while an agent is inferring or the host is quiesced.

— Hamish-2569 (Opus), contributing through nissa-seru

Hamish-2569 and others added 5 commits October 15, 2026 10:01
… their text

addPolicy and removePolicy (wake_add_rule and wake_remove_rule, and the
framework's addGatePolicy and removeGatePolicy) read gate.json again
before a change, and when it was there but wouldn't load, they fell back
to the rules in force and wrote those over it. Whatever the file held,
such as an operator's unfinished edit, was lost, though addPolicy's doc
says a concurrent operator edit isn't clobbered.

Now the bytes the change read are first copied, with an exclusive
create, to gate.json.unreadable-<UTC time> beside it, such as
gate.json.unreadable-20261015T025905Z (no colons, which Windows
refuses), with -2 and on when the name is taken. Then the change is
written into gate.json in place, as before. A copy rather than a rename,
so a symlinked gate.json keeps its link, the file keeps its mode and
owner, and a single-file bind mount works; what's kept is the bytes that
were judged, whatever their encoding (a Latin-1 edit, or UTF-16 as
Windows PowerShell 5's > writes it, would not survive decoding and
encoding again), and a copy never replaces an earlier one. The change is
told where the copy is through a new onUnreadableKept option; the
wake-rule tools put that in their result as keptUnreadable, and a [gate]
log line and a gate:config-kept trace (now in the TraceEvent union) say
the same. When the copy can't be made, the change throws before anything
is written or any rule's state is cleared, and a write that fails after
the copy names it. A gate.json the process can't read at all refuses the
change: there's nothing to keep, and a file the process can't read is
often one its operator closed off on purpose. A change that writes
nothing (a placement it can't make, or removing a rule that isn't there)
keeps nothing. gate_status lists each copy still beside gate.json, in a
new keptCopies field, with what to do with it, until it's removed: the
moment a copy is made reaches only the log, the trace and the change's
caller (channel-mode in connectome-host passes no callback), and the
copy exists to be found later.

Reloading: at most once a second, gate.json is read again and applied
when its text differs from the text whose rules are in force (configText,
set only by a load or a write that applies it), and the privileged-users
file is loaded when its text differs from the text last read (a failed
read forgets that one). A gate.json that holds the text in force again
after failing to load, as after a chmod repair, has its error cleared and
nothing applied again, so the startup's error isn't cleared by a spell
when the file couldn't be read. Before, gate.json's reload compared
only its mtime, taken after the read, so two writes in one clock tick, a
write between the read and the stat, or a read that only looked
(listPolicyNames, a change that wrote nothing) left a new gate.json
unapplied until it changed again, and a chmod that made it readable
wasn't noticed. Recording the text only once it's in force also means a
reload that throws before applying is tried again. isPrivilegedUser,
which tune-out's wake classification calls for each diverted message,
read and parsed the privileged-users file on every call, and while it
couldn't be read each call pushed another copy of its error onto
gate_status's errors; it now takes the throttled check, and so does the
sleep path, through isPrivilegedUser, so an empty author id is never
privileged there either. A clock stepped backwards makes the next check
run rather than hold off both reloads until it catches up. A missing
file is ENOENT from the read itself, not existsSync, which also said
"absent" for a file behind a directory the process can't search.

The privileged-users file loads entry by entry: a non-empty string or a
safe integer is a user id, and any other entry is skipped and named in
the file's error, including a number too large for JSON to read exactly,
whose rounded value could be another user's id. A file that isn't JSON,
or isn't a list or {"userIds": [...]}, is that file's error, and the
list that last loaded in this process stays (none, at startup); it used
to load as no one privileged, without a word.

removePolicy clears the removed rule's state (delivering its pending
debounce batch, dropping its rate-limit and sampling counters) only
after the write succeeds, so a failed keep or write leaves the rule in
force with its state. It used to clear first. The write stays in place,
not a temporary file and a rename: a rename fails on a gate.json that's
a single-file bind mount, needs a writable directory, replaces a symlink
with a file, and resets the file's mode and owner.

gate_status: handleToolCall runs both throttled reloads before it reads,
so what it shows is at most a second old. Its errors, a list that every
gate.json load replaced and every privileged-users failure appended to,
becomes one current error for gate.json, one for the startup's seeding or
reconciling of gate.json (kept apart, so the constructor's own load no
longer clears it at once; it goes when gate.json loads new text or is
written), and one for the privileged-users file. A missing gate.json
clears its error. gate:config-error is traced when gate.json's error
changes, rather than at every read that fails the same way.

test/event-gate-unreadable-files.test.ts covers keeping (by add and
remove, for a JSON error and for a policy the validator refuses, the -2
name, the trace, the log line and gate_status's list), a gate.json that
isn't UTF-8 kept byte for byte, a symlinked gate.json keeping its link
and mode, nothing kept when nothing is written or the file is missing, a
gate.json that can't be read refusing, a copy that can't be made writing
nothing and keeping the rule's pending batch, a write that fails after
the copy naming it, a remove whose write fails keeping its rule and
batch, a text only looked at being applied by the next reload, the
reload's skip of unchanged text, its throttle and a clock stepped back,
both files reloading by text (same size and mtime, and a chmod repair), a
removed gate.json taking its error, files behind a closed directory as
errors, the startup's error beside a gate.json that loads, the trace
once, the privileged-users file's shapes and entries, its throttle, the
sleep path, gate_status reading first, the errors kept apart, and what
wake_add_rule and wake_remove_rule tell the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keepUnreadableConfig created gate.json.unreadable-<time> with the
process's default mode, 0666 less the umask, so the copy of an
owner-only gate.json could be read by any local user who can search its
directory, which gate.json itself couldn't (Greptile on anima-research#371). The copy
is now created owner-only, then given gate.json's group, and only then
gate.json's permission bits, so at no point can anyone read it who
couldn't read gate.json: an owner-only file's copy is owner-only, and a
group-readable one's can be read by the same group, so a group member
who made the half-made edit can get it back from the copy. A copy that
can't be given gate.json's group, because the process isn't in it, or
whose gate.json can't be stat'd, stays owner-only. gate.json's bits
alone would leave the copy in the process's own group, readable by that
group's members rather than gate.json's, as a probe showed, and a fixed
0600 would shut out a group that shares gate.json.

test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json
give copies of the same mode and group; a gate.json in another of the
process's groups gives a copy in that group (it skips where the process
is in no other group, as on our runs); and a gate.json linked to a file
whose group the process isn't in, such as root's /etc/hosts, gives an
owner-only copy, kept by calling the keep directly so that nothing is
written through the link. Tests, on Linux, Node 24.21.0: of the twelve
gate and tune-out files, 173 tests pass and 1 skips, the test of another
group, since the user our runs use is in no group besides its own.
Red-first on main's src (92125b0) with this test file: 27 of its 30
tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at
least one test of the file, including a copy created with the default
mode or with gate.json's bits, no chown, no chmod, and the chmod before
the chown. The test of another group, and a probe, also ran on a host
where the process is in other groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…json that won't load

keepUnreadableConfig gave the copy gate.json's group with
chownSync(kept, -1, gid), -1 meaning "leave the owner as it is". Bun
1.3.14, the version connectome-host's CI and release workflows pin,
refuses a uid of -1 with EPERM even for the file's own group, and the
shared catch then skipped the chmod too, so under Bun the copy always
stayed owner-only and a group that shares gate.json couldn't read it.
The copy is the process's own file, so the chown now passes the
process's uid, which an owner may always keep (-1 where there's no
geteuid, as on Windows).

A gate.json that stopped loading reached only the gate:config-error
trace, so an operator whose edit didn't load found nothing in the log.
When gate.json's error changes, configUnreadable now logs one [gate]
line saying it couldn't be read and why, so each distinct error is
logged once.

test/event-gate-unreadable-files.test.ts: a copy of a 0640 gate.json is
0640 where chownSync refuses a uid of -1, as Bun 1.3.14 does; a change
over a gate.json that won't load logs the failed load, then the copy;
and a failure read again is logged once, and a new one once more.
Tests, on Linux: of the twelve gate and tune-out files, 174 of 175
pass and 1 skips, the test of another group, since the user these runs
use is in no group besides its own. Red-first on the previous commit's
src with this test file: 3 of its 31 tests fail, these three, 27 pass
and 1 skips. Removals: each of 52 pieces fails at least one test of the
file, among them a chown that passes -1 again and the log line taken
out. A script that keeps a copy of a 0640 gate.json that won't load got
a 0600 copy under Bun 1.3.14 before this change and gets a 0640 one
after, as under Node 24.21.0 and Bun 1.4.3. The whole suite passes:
2259 of 2260 tests with 1 skipped, file by file in 145 files on Node
24.21.0, and the same on Node 20.20.2.
…obably meant

closestNames(name, candidates, max = 2) ranks candidates for a refusal's
"did you mean", ignoring case: an equal name; then one the name matches
as a whole `--` segment (`ls` for `workspace--ls`); then one that starts
with the name or that the name starts with; then one that contains it or
that it contains. These two need three characters on the shorter side
and count however many edits they take. Last comes one a few edits away:
at most a quarter of what follows the prefix the two share, and at least
one, since a shared prefix is mostly a namespace, so `workspace--write`
isn't offered for `workspace--read`. Within a rank, fewer edits come
first, ties keep the order given, and a candidate given twice is named
once. Where one name holds the other, the length difference is already
their distance, so a long name, such as a catalogue pasted in by mistake,
runs no edit table for each candidate.

isNearMiss(name, candidate) says whether a name is a slip for another:
the same ignoring case; within that edit limit, a transposition counting
as one edit; or, at three characters or more, a part of it, which is a
shortened attempt at it (`mode` for `deliveryMode`). A part counts only
that way round: a name that holds the candidate, past the edits a slip
allows, is another name, usually an extra that explains it
(`channel_note` beside `channel`).

The tests pin each rank against the next, a name that starts with or
holds its candidate, the cut-off and how it scales past a shared prefix,
ties, repeats, and isNearMiss' slips and non-slips, and, as a hang guard,
a million-character name that holds all of a hundred candidates.
…s it ran

A wake rule's slips used to change what it does without a word, in gate.json
and through wake_add_rule alike:
- validatePolicy saved any behavior other than "always", "defer", "skip" or
  an object as "always", a rule that wakes on every event it matches. That
  covered a typo such as "deffer", and, in membrane's XML tool mode (0.5.87
  parses a parameter by its declared type), the object form wake_add_rule's
  own description showed: `behavior` is declared a string there, so
  {"debounce": 60000} arrived as text and was saved as "always".
- A match field of the wrong type was dropped: {match: {tagsAny:
  "chat:ambient"}, behavior: "skip"} was saved as match {}, a rule that skips
  every event, DMs and mentions included (issue anima-research#105's hazard, reached by a
  type slip).
- A field name one slip from a known one (pasthrough, keyby, polices) was
  ignored, so the field meant was missing: pasthrough: true made a governor
  consume the events it should let fall through. wake_add_rule's own
  parameters were taken by name, so a slip of one was dropped the same way.
- So was a field put at the wrong level: {name: "quiet", tagsAny:
  ["chat:ambient"], behavior: "skip"} skipped every event, mentions
  included, and addPolicy saved it as match {}; wake_add_rule given tagsAny
  as a parameter of its own saved its rule with match {} too. A list of
  rules under a name gate.json doesn't read, such as "policy", and a field
  of a rule, or of its match or behavior, at its top loaded as no rules
  under its default, and a change wrote the rules in force over them.
- A behavior object holding two kinds used debounce, then rate_limit, then
  passive_sample, whatever the order; a keyBy or resets of the wrong type
  was dropped; and a gate.json whose "policies" wasn't a list, or which was
  a list, loaded as no rules under its default.
Probes at main 92125b0 showed the behavior, match, policies and
wrong-level cases.

Each is named now, every one a rule has, not only the first. What's done
with a rule that has one depends on who can fix it:
- wake_add_rule and addGatePolicy refuse it, naming every problem the rule
  has, and save nothing: their caller is there to fix it. wake_add_rule's
  checks of its own parameters come first, each refused on its own, a field
  of match, rateLimit or passiveSample given as one of them included, naming
  the parameter it belongs in.
- gate.json and the host's starting rules are what a deployment already
  runs, so there a rule with a problem is read the way main read it, with a
  note in gate_status's errors and a [gate] log line, and the rest of the
  file loads. A rule is left out only where main's validator refused it. One
  that skips or defers is read past a problem as main read it too, holding
  back what main held back: left out, the events it held back would go on
  to the rules after it, where a rate limit, a sample or a debounce would
  spend them and then hold back an event main woke on. A probe that sends
  one stream of events through both gates found 72 such events in 64 of 144
  configurations where an earlier version left these rules out.
- Under a default that holds events back, "skip" or "defer", a file that
  leaves out a rule that could wake the agent isn't used: it would hold back
  what that rule was written to wake on, and main, which refused such a
  rule and so the whole file, didn't use it either. A start then runs the
  host's starting rules, or none, which hears every event, and a reload
  keeps the rules in force. A rule left out that wakes on none of what it
  matches (one that skips or defers, or a rate limit whose tokens are a
  number at or under 0) holds back nothing it was written to wake on, so it
  doesn't count, and a deliberately quiet file loads as it did; one whose
  tokens are missing or not a number, or whose behavior is a list or names
  no kind, was written to wake on something, so it counts.
The promise: no event that woke the agent on main is silenced, unless main
refused the whole file and so ran something else in its place (at a start
the host's starting rules or none, at a reload the rules it had), or
refused one of the host's starting rules and so ran them all unvalidated.
What remains of that exception, under "always", is an event main woke on,
having refused the file, that a rule after the one left out now holds back:
one that skips or defers it, a rate limit or a sample that lets only some
through, or a debounce that delays it. Finding those would mean judging what
each rule matches.

How each class is read, from main's validatePolicy at 92125b0:
- Left out, main having refused it: a rule that isn't an object or has no
  name; a behavior that's a list, or an object holding no kind; a rate_limit
  or passive_sample that isn't an object or lacks tokens, refillIntervalMs or
  every; a debounce, tokens, refill or every that isn't a finite number; a
  rate_limit with tokens at or under 0 (it woke on nothing); an invalid
  regex filter; and a passthrough that isn't a boolean or sits on a behavior
  that doesn't count. For the starting rules, main ran these raw, from
  memory, once the gate.json it seeded failed to load, and some woke the
  agent; they stay left out and named at start, rather than carry main's
  accidents into the reader.
- Read the way the starting rules ran it, in gate.json too, with a note: a
  debounce outside 100-300000 ms at its nearest bound; a passive_sample
  every at or under 0 as 1, and a fraction as its next whole number; and a
  refillIntervalMs at or under 0 as a bucket that never refills, so it wakes
  on its first tokens and then on nothing it matches. applyRateLimit refills
  only for an interval over 0, and so does wouldTrigger, which probe() and
  wake_add_rule's before-and-after table read. A refill of 0 refilled once
  on main, since floor(elapsed / 0) is Infinity and lastRefill += Infinity *
  0 is NaN; main refused it in gate.json and ran it only from the starting
  rules, which the exception covers.
- Read as main read it, with a note, a rule that skips or defers included,
  whose note adds that it skips or defers every event it now matches, as
  main did: a behavior that isn't a name or an object (typo, number, the
  object form as text) as "always", a slip of "behavior" itself included;
  two kinds, by main's precedence; a match that isn't an object as matching
  every event; a match field of the wrong type, a slip of one, an entry of
  the wrong type or "" in a list, a filter main couldn't read, a field the
  gate reads at another level, named with where it's read, and a keyBy or
  resets of the wrong type, or a slip of keyBy, resets or passthrough, each
  dropped. In a rule the gate reads, a match left with nothing that narrows
  it, once something in it was named, gets a note that it matches every
  event. In a refusal of such a rule, where nothing was saved, the note says
  the rule as written would match every event.
- An empty scope entry, which main kept and which narrows a rule, since no
  event's type is "", is read as main read it whatever the rule does.
- gate.json itself: one that's a list, or whose policies isn't a list and
  holds something, or is missing or holds nothing beside a slip of it
  holding something, main loaded as no rules under its default; it is read
  so, with a note, where that default is "always", and isn't used where it's
  "skip" or "defer". Where policies is missing or holds nothing, a field of
  a rule, or of its match or behavior, says a rule was meant: a list holding
  an object with such a field, under any name gate.json doesn't read, is
  such a slip, and so, under "skip" or "defer", is such a field at the top;
  main used such a file, with none of those rules in force. A rule's name, a
  filter's type and a keyBy shape nothing on their own, and nor does a field
  of a rule or its match holding nothing, an empty list or an object holding
  only empty values, since an empty one has always meant no constraint, so a
  label of the file can have their names, or hold nothing: at the top, or in
  a list whose objects hold no field that says a rule was meant, they load
  as written, as on main, with a note, and a change keeps them. A behavior,
  or one of its kinds, counts even when it holds nothing, as one does in a
  rule left out; a null or "" is a field left out, in a list's objects as at
  the top. A label named as such a field, such as "source": "written by
  ops", counts as that field, since nothing in a name tells the two apart. A
  policies that isn't a list, or a slip of it, holding nothing, such as
  "policies": {} or "polices": [], writes no rules, as [] does: it loads as
  no rules, as on main, with a note saying what its default then does, and a
  change writes the list in place of such a policies, or beside such a slip.
  Beside a policies holding nothing, [] included, a slip of it holding
  something, a list of rules under another name and a field at the top that
  says a rule was meant are read as they are with no policies. A list
  holding no rule, such as "comments": ["quiet for now"] or "policy": [],
  loads as written. A slip of "default" reads as "always", as on main. JSON
  that won't parse, a non-object, and a default other than the three are
  refused whole, as on main. A policies left out, null, "" or [] writes no
  rules, and loads so under its default, as on main.
- A null is a field left out, and so is "", with a note that says what
  leaving it out does where that's more than no constraint: a match left
  out matches every event, and so does one whose fields are all ""; a
  behavior is "always"; a keyBy leaves one bucket or count for every event
  the rule matches; gate.json's default is "always"; and its policies leave
  no rules, which under "skip" or "defer" holds back every event. main read
  most fields that way; where it refused a rule or the file for one (a
  passthrough, a behavior's kind, gate.json's default), the rule or file
  now loads without it.

The reader is one walk, readRule, that collects every problem with the
reading main gave it (or none, where main refused the rule); readConfig reads
a whole gate.json or starting config with it, and ruleOutcome decides each
rule: as written, read, or left out. validatePolicy and validateConfig go.

Around it:
- The starting rules are read rule by rule, at the first start and in the
  reconcile, the same way gate.json is, so the condition above applies to
  them too: under a starting default of "skip" or "defer", a starting rule
  left out that could wake the agent means none of them is used, and the
  start runs gate.json's rules, or none, which hears every event. A first
  start writes the starting config as given, since gate.json is read the
  same way, so its load names each problem until someone fixes it.
- A write edits gate.json's own rule list by name: addPolicy and
  removePolicy, behind wake_add_rule and wake_remove_rule, and the
  reconcile, write back every rule they don't change as it was written,
  problems and unknown fields included, and gate.json's own fields with
  them. The rules in force are then read from the text written, as a load
  reads it, so what gate.json still holds that the gate reads past or leaves
  out stays named. A rule left out can be replaced or removed by its name.
  Before, a write wrote the rules as the gate held them: a "comment" didn't
  survive one, and a rule left out would have dropped from the file.
- A gate.json that's JSON the gate doesn't use is edited the same way, and
  only what loads is written: a change that doesn't mend what keeps the
  file out is refused, naming that and what would bring it into force,
  worked out from the file as it is, and the file stays as its operator
  wrote it. Where one named rule keeps it out, replacing or removing that
  rule by name brings it into force, and the refusal says so; where a rule
  with no name, two rules or a bad default keep it out, no one change can,
  and the refusal says whoever runs the host has to fix gate.json. Where
  the rule shares its name with another, a replacement by that name takes
  the first of them and a removal all, so the refusal says that instead of
  naming a change that would replace or remove a sound rule.
  A change used to write the rules in force over such a file, losing its
  other rules, its own fields and its default.
- When gate.json is missing, can't be read or isn't JSON, a change edits
  the list of the text whose rules are in force the same way, after keeping
  a copy of a file that can't be read or isn't JSON, and writes the rules
  in force only where there's no such text or it has no rule list. So does
  a change after a start whose reconcile write failed partway: the text it
  was writing is the text in force. The copy's log line, keptCopies entry
  and keptUnreadable say gate.json "couldn't be used", true of both.
- A rule gate.json holds holds its name, whether the gate reads it or
  leaves it out, so the reconcile adds only starting rules whose names the
  file lacks. Why: an operator's "ambient" skip rule narrowed to Zulip, with
  an empty tagsAny entry main read past, holds its name; had the reconcile
  added the host's "ambient", which skips all ambient chat, Discord's
  ambient chat would go quiet under the operator's "always" default, where
  main woke on it. It takes both halves: the name held, and the rule kept
  through every write, over a gate.json that won't load included.
- The reconcile never writes a gate.json it would then refuse: where adding
  the starting rules would leave out one that could wake the agent under
  gate.json's own "skip" or "defer" default, it adds none, and the startup
  entry says why.
- A gate.json with no rule list a write could edit without writing over or
  leaving out what it holds (a list, a policies that isn't one and holds
  something, a slip of policies holding something where policies is missing
  or holds nothing, which holds the operator's rules, a list of rules under
  another name, a field at the top that says a rule was meant, under "skip"
  or "defer", a label named as one included, or JSON that isn't an object)
  refuses a change, an addition or a removal, whatever its default, naming
  the fix and who can make it, and saves nothing; the reconcile adds nothing
  to it and says so. A removal there used to answer that no rule had the
  name, though the file can hold it, so a host's removeGatePolicy meets the
  same refusal as its addGatePolicy. An addition there used to write the
  rules in force over the file, dropping the operator's rules; a list added
  beside them would bring the file into force with only the new rule. Where
  gate.json can't be read at all, nothing is kept and a change is refused,
  as before, and the refusal now says whoever runs the host can make it
  readable, and that a change is refused the same way until then.
- Also: a field name the gate reads nowhere and near none it reads, such as
  a "comment", loads and is named, in gate_status's errors, the result's
  ignoredFields, or onIgnoredFields; a slip is named with every field it
  could be meant for (isNearMiss and closestNames, in the commit below this
  one); wake_add_rule's own parameters are checked the same way, and it
  refuses a behavior, position, insertBefore or insertAfter of the wrong
  kind in its own terms, its example giving debounceMs: 60000; what the
  start did is an entry of gate_status's errors worded about the start, kept
  until the next start, beside an entry for rules the start couldn't save,
  where a failure in writing or closing, unlike one opening, may have left
  gate.json part-written; the startup entry checks the names gate.json
  holds, not the rules it runs, before it says a starting rule wasn't added;
  a reconcile whose write leaves gate.json unable to load runs what it read
  plus what it was adding, and its entry, which names what those hold that
  the gate reads past or leaves out, goes once gate.json is written, or a
  reload applies its text or finds it holding the text in force;
  configSource is "initial" while the starting rules run; gate_status's
  entry for a gate.json the gate doesn't use says what's in force instead:
  the rules of the last gate.json it used, the rules a start read and was
  adding when its own write left gate.json unable to load, the host's
  starting rules, or none; a loaded gate.json's notes stay with the text in
  force (configNote); the log line for a gate.json that won't load says it
  "couldn't be used", as the copy's texts do; and a value a message quotes,
  over 80 UTF-16 units, and a skipped privileged-users entry, over 40, are
  cut with safeSlice, so neither ends on half a surrogate pair.
The gate's other messages keep their wording.

Tests, on Linux, Node 24.22.0: test/event-gate-strict-rules.test.ts, 26
tests, among them each class read or left out in gate.json with the events
it wakes on, a rule that skips or defers read past a problem as main read
it, ahead of a rate limit that spends its tokens only on what reaches it, a
file that leaves out a rule that could wake under "skip" or "defer" not
used, at a start, with and without starting rules, at a reload, and as a
starting config, beside the rules left out that don't count, every problem
named at once, a field at another level read past in gate.json, with how the
rule then reads for a field of each level, and refused by addPolicy and
wake_add_rule, a list of rules under "rules" read as a slip of "policies",
and one under "policy", a rule's fields, a match field, a rate limit's, a
filter's type beside its pattern and a "source" label at the top not used
under "skip" or "defer", with lists that hold no rule loading as written,
and a rule's name, a filter's type or a keyBy at the top, or a list of
objects holding no field that says a rule was meant, a field of a rule or
its match holding nothing, and a policies or a slip of it holding nothing,
loading as written, quiet, and kept by a change, a value a rule would drop
and a behavior or kind holding nothing at the top, or in a list's object,
not used, a slip holding a rule, a list of rules under another name or a
rule's fields beside a policies holding nothing not used, a change refused
there, a list holding null or an object whose rule fields are null or ""
loading as written, and a list mixing a label's object with a rule not used,
gate_status's entry saying what's in force, after a start whose write left
gate.json unable to load too, the starting rules rule by rule, a write
keeping the rules it doesn't change, over a gate.json that isn't JSON too, a
change refused where gate.json has no rule list, a slip of "policies" and a
"source" label among them, a change over JSON the gate doesn't use refused
unless it mends it, with the file left as written and the refusal naming
what would bring it into force, and the operator's "ambient" holding its
name through a change, a restart and a change mid-edit.
event-gate-rate-limit's validation tests pin tokens of 0 and -2 left out and
a refill at or under 0 read, with the bucket never refilling and the probe
saying so at each step; event-gate-unreadable-files' gate.json refused
whole, for a default other than the three, is now neither written over nor
kept, the refusal says whoever runs the host has to fix it, the log line
says "couldn't be used" once, its copy says "couldn't be used", a gate.json
that won't load has an entry saying what's in force, and one that can't be
read refuses a change saying whoever runs the host can make it readable;
event-gate's two debounce-bounds tests pin an out-of-range debounce in
gate.json read at its bound, where it was refused whole. Red-first on the
parent commit: 40 of the four changed files' 135 tests fail and 1 is skipped
(25 of the 26 strict-rules tests, the round-trip control passing; 3
rate-limit; 2 event-gate debounce bounds; 10 unreadable-files). Removals:
each of 186 pieces, undone on its own, fails at least one test. Fourteen
files (event-gate's nine, closest-names, mcpl-gate-roundtrip, api-admin-gate
and tune-out's two): 206 of 207 pass, 1 skipped (the other-group test).
Probes that run main's gate beside this one: of 142 gate.json
configurations, none silences an event main woke on where main loaded the
file (146 events where main refused it, the exception); one stream of events
through each gate, over 144 configurations, a skip or defer rule ahead of a
rate limit, a sample or a debounce, with one of eight problems or none (the
controls): every event decided the same on both; the recipes and other
starting rules, first and later start: none; an operator's narrowed
"ambient" through a change, a restart and a change over an unloadable
gate.json: Discord's ambient chat wakes on both; every waking rule left out
under "skip" or "defer": every event wakes on both; a field at the wrong
level, a list under "policy", and a rule's fields, a match field or a rate
limit's at the top, with starting rules and without: none goes quiet, and
the saves and writes main made of them are refused; labels at the top named
name, type or keyBy, a list of objects holding none of a rule's fields, and
fields of a rule or its match holding nothing, at the top or in a list's
object: decided alike on both, and an addition keeps them where main wrote
over them; a policies or a slip of it holding nothing: decided alike on
both, and an addition writes its list there, keeping a slip beside it; a
"source" label, a filter's type beside its pattern, a value a rule would
drop, a behavior or kind holding nothing, and a slip holding a rule, a list
of rules or a rule's fields beside a policies holding nothing: not used, as
a match field at the top isn't. connectome-host's four recipes load with no
errors on a first and a later start: clerk 5 rules, knowledge-miner 3,
knowledge-reviewer 3, zulip-miner 2. The suite: merged onto main 3adbdb8,
each of its 155 test files run on its own on Node 24.22.0 passes 2496 of
2497 tests, with 1 skipped, which needs a second group the run's user isn't
in; main run the same way has 152 files and 2434 tests, and the three new
files hold the other 63.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[Medium impact] The changes affect how one event-gating workflow reads and edits wake rules.

Fix the hidden match constraints and stale counting state before merging.

Findings

  1. P1 Hidden fields silence events ▶
  2. P1 Restored rules keep stale counts ▶
  3. P2 Security Backups can expose private files ▶
  4. P2 Refusals miss nested errors ▶
  5. P2 Failed calls hide ignored parameters ▶
  6. P2 Release notes describe old behavior ▶
Fix with agent prompt
### Issue 1
src/gate/event-gate.ts:569
`withoutEmpty` assigns arbitrary keys to `{}`, so an own `__proto__` key changes the copy's prototype instead of remaining an ignored field. A JSON rule with `match: {"__proto__":{"source":"discord"}}`, `behavior: "always"` and a `"skip"` default previously woke on every event. The new reader silently adds the inherited `source` constraint, so other sources stop waking the agent.

Use a copy that preserves own keys without invoking setters, such as `Object.fromEntries` or a null-prototype object.

```suggestion
  const out: Record<string, unknown> = Object.create(null);
```

### Issue 2
src/gate/event-gate.ts:2308-2311
Applying `read.config` here can remove a rule without clearing its old state. Start with a one-token rule and spend its token. Then change its on-disk `tokens` to `0` under `"always"` and add an unrelated rule before reload. This write leaves the broken rule out, but keeps its empty bucket and records `configText`, preventing reload cleanup.

Restoring that rule with five tokens then treats it as new and reuses the empty bucket, denying events that should wake the agent. Clear state for rules removed or changed by the applied reading, as the reload path does.

### Issue 3
src/gate/event-gate.ts:1984-1985
If `gate.json` is a symlink in a searchable directory pointing to a `0644` file inside a private `0700` directory, other users cannot read the original. But the backup is created beside the symlink and changed to `0644`, exposing its contents. Copying the target's permission bits does not preserve its parent directory's protection.

Keep symlink backups owner-only unless equivalent access can be preserved.

**How this was verified:** The copy path uses the link's directory, while `statSync` follows the target and `chmodSync` copies its mode without checking the target's parent directories.

### Issue 4
src/gate/event-gate.ts:775-777
`readBehavior` checks only the first supplied kind. For `behavior: { debounce: 1000, rate_limit: { tokens: "bad" } }`, `addPolicy` reports the two kinds but omits both the bad `tokens` value and missing `refillIntervalMs`. Removing `debounce` produces another refusal instead of having named every problem the first time.

Collect diagnostics from every supplied kind for strict additions while preserving the legacy first-kind choice for file loads.

### Issue 5
src/framework.ts:18184-18187
Notes collected by `checkToolInput` are returned only after `addGatePolicy` succeeds. For `{name: "watch", matches: {source: "discord"}, behavior: "deffer"}`, the refusal names the behavior typo but drops the note that `matches` is ignored. The same loss happens when the gate itself refuses the rule.

Carry these notes into failed results too. Otherwise, fixing only the reported error can save a rule that matches more events than intended.

### Issue 6
changelog.d/unreadable-gate-files.fixed.md:1-4
This fragment still describes the earlier stacked change. It says JSON containing a refused policy is copied and overwritten, but the new code refuses changes unless they make that JSON usable. It also says the startup error clears after new text loads or a write succeeds, while `startNotice` now stays until the next start.

Update these statements so operators know whether the tools can repair the file and why a startup notice remains.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds detailed wake-rule checks, preserves untouched file content during rule changes, and reloads gate files by their text.

  • Wake rules name input problems instead of letting them go unnoticed.
  • Rule changes keep the rest of gate.json as written.

Reviews (1) · Last reviewed commit: "fix(gate): name every problem a wake rul..." · Reviewed by Greptile

Comment thread src/gate/event-gate.ts Outdated
function withoutEmpty(
where: string, obj: Record<string, unknown>, notes: string[], means: Readonly<Record<string, string>> = {},
): Record<string, unknown> {
const out: Record<string, unknown> = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hidden fields silence events

withoutEmpty assigns arbitrary keys to {}, so an own __proto__ key changes the copy's prototype instead of remaining an ignored field. A JSON rule with match: {"__proto__":{"source":"discord"}}, behavior: "always" and a "skip" default previously woke on every event. The new reader silently adds the inherited source constraint, so other sources stop waking the agent.

Use a copy that preserves own keys without invoking setters, such as Object.fromEntries or a null-prototype object.

Suggested change
const out: Record<string, unknown> = {};
const out: Record<string, unknown> = Object.create(null);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 569

Comment:
**Hidden fields silence events**

`withoutEmpty` assigns arbitrary keys to `{}`, so an own `__proto__` key changes the copy's prototype instead of remaining an ignored field. A JSON rule with `match: {"__proto__":{"source":"discord"}}`, `behavior: "always"` and a `"skip"` default previously woke on every event. The new reader silently adds the inherited `source` constraint, so other sources stop waking the agent.

Use a copy that preserves own keys without invoking setters, such as `Object.fromEntries` or a null-prototype object.

```suggestion
  const out: Record<string, unknown> = Object.create(null);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 80503bef. The copy is now built with Object.fromEntries, so a "__proto__" stays an own field: what it holds narrows nothing, and the gate names it as a field it doesn't read.

Written by Hamish-2569.

— Tabitha-2696

Comment thread src/gate/event-gate.ts
Comment on lines +2308 to +2311
this.configText = text;
this.configNote = loadNote(read.sentences);
this.config = read.config;
this.compiledPolicies = this.compilePolicies(read.config);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Restored rules keep stale counts

Applying read.config here can remove a rule without clearing its old state. Start with a one-token rule and spend its token. Then change its on-disk tokens to 0 under "always" and add an unrelated rule before reload. This write leaves the broken rule out, but keeps its empty bucket and records configText, preventing reload cleanup.

Restoring that rule with five tokens then treats it as new and reuses the empty bucket, denying events that should wake the agent. Clear state for rules removed or changed by the applied reading, as the reload path does.

Knowledge Base Used: Event gate policies and scripts

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 2308-2311

Comment:
**Restored rules keep stale counts**

Applying `read.config` here can remove a rule without clearing its old state. Start with a one-token rule and spend its token. Then change its on-disk `tokens` to `0` under `"always"` and add an unrelated rule before reload. This write leaves the broken rule out, but keeps its empty bucket and records `configText`, preventing reload cleanup.

Restoring that rule with five tokens then treats it as new and reuses the empty bucket, denying events that should wake the agent. Clear state for rules removed or changed by the applied reading, as the reload path does.

**Knowledge Base Used:** [Event gate policies and scripts](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/event-gate-policies.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 80503bef. A write now clears the state of each rule it leaves out of force or gives another behavior, through the reload's cleanup, which both now call. A rule restored later starts on a fresh bucket with no counts.

Written by Hamish-2569.

— Tabitha-2696

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +1984 to +1985
chownSync(kept, process.geteuid?.() ?? -1, gateFile.gid);
chmodSync(kept, gateFile.mode & 0o777);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Backups can expose private files

If gate.json is a symlink in a searchable directory pointing to a 0644 file inside a private 0700 directory, other users cannot read the original. But the backup is created beside the symlink and changed to 0644, exposing its contents. Copying the target's permission bits does not preserve its parent directory's protection.

Keep symlink backups owner-only unless equivalent access can be preserved.

How this was verified: The copy path uses the link's directory, while statSync follows the target and chmodSync copies its mode without checking the target's parent directories.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1984-1985

Comment:
**Backups can expose private files**

If `gate.json` is a symlink in a searchable directory pointing to a `0644` file inside a private `0700` directory, other users cannot read the original. But the backup is created beside the symlink and changed to `0644`, exposing its contents. Copying the target's permission bits does not preserve its parent directory's protection.

Keep symlink backups owner-only unless equivalent access can be preserved.

**How this was verified:** The copy path uses the link's directory, while `statSync` follows the target and `chmodSync` copies its mode without checking the target's parent directories.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3aa56d8e on #371, which this builds on and now merges: the copy of a gate.json that's a symbolic link stays owner-only.

Written by Hamish-2569.

— Tabitha-2696

Comment thread src/gate/event-gate.ts Outdated
Comment on lines +775 to +777
if (kind === 'debounce') return { behavior: readDebounce(r, b.debounce), kind };
if (kind === 'rate_limit') return { behavior: readRateLimit(r, b.rate_limit), kind };
return { behavior: readPassiveSample(r, b.passive_sample), kind };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refusals miss nested errors

readBehavior checks only the first supplied kind. For behavior: { debounce: 1000, rate_limit: { tokens: "bad" } }, addPolicy reports the two kinds but omits both the bad tokens value and missing refillIntervalMs. Removing debounce produces another refusal instead of having named every problem the first time.

Collect diagnostics from every supplied kind for strict additions while preserving the legacy first-kind choice for file loads.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 775-777

Comment:
**Refusals miss nested errors**

`readBehavior` checks only the first supplied kind. For `behavior: { debounce: 1000, rate_limit: { tokens: "bad" } }`, `addPolicy` reports the two kinds but omits both the bad `tokens` value and missing `refillIntervalMs`. Removing `debounce` produces another refusal instead of having named every problem the first time.

Collect diagnostics from every supplied kind for strict additions while preserving the legacy first-kind choice for file loads.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 80503bef. A refusal now names the problems of each kind a behavior holds, after the kind it reads, and a load reads past them as before.

Written by Hamish-2569.

— Tabitha-2696

Comment thread src/framework.ts Outdated
Comment on lines +18184 to +18187
const ignored: string[] = [];
const parameters = AgentFramework.WAKE_RULE_TOOLS[0]!.inputSchema.properties as Record<string, unknown>;
const input = checkToolInput(
'wake_add_rule', (call.input ?? {}) as Record<string, unknown>, parameters, ignored, { match: 'the rule matches every event' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed calls hide ignored parameters

Notes collected by checkToolInput are returned only after addGatePolicy succeeds. For {name: "watch", matches: {source: "discord"}, behavior: "deffer"}, the refusal names the behavior typo but drops the note that matches is ignored. The same loss happens when the gate itself refuses the rule.

Carry these notes into failed results too. Otherwise, fixing only the reported error can save a rule that matches more events than intended.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18184-18187

Comment:
**Failed calls hide ignored parameters**

Notes collected by `checkToolInput` are returned only after `addGatePolicy` succeeds. For `{name: "watch", matches: {source: "discord"}, behavior: "deffer"}`, the refusal names the behavior typo but drops the note that `matches` is ignored. The same loss happens when the gate itself refuses the rule.

Carry these notes into failed results too. Otherwise, fixing only the reported error can save a rule that matches more events than intended.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 80503bef. Every refusal of wake_add_rule now names the parameters it doesn't take, and the refusal of a slip or a misplaced parameter names them in its own sentence.

Written by Hamish-2569.

— Tabitha-2696

Comment on lines +1 to +4
- A wake-rule change no longer destroys a gate.json that won't load. `wake_add_rule` and `wake_remove_rule` (and `addGatePolicy` and `removeGatePolicy`) start from the rules in force when gate.json is there but isn't JSON or holds a policy the gate refuses, and they wrote those rules straight over the file, so whatever it held, such as an operator's unfinished edit, was lost. Now a copy of the file, byte for byte, is kept first, at `gate.json.unreadable-<UTC time>` beside it (such as `gate.json.unreadable-20261015T025905Z`, with `-2` and so on if that name is taken), and then the change is written into gate.json in place, as before, so a symlinked gate.json keeps its link, the file keeps its mode and owner, and a single-file bind mount still works. The copy is no more readable than gate.json: it's created owner-only, then given gate.json's group, and then its permission bits, and a copy that can't be given gate.json's group, because the process isn't in it, stays owner-only. The tool's result says where the copy is, in `keptUnreadable`, and so do a `[gate]` log line and a `gate:config-kept` trace. When the copy can't be made, nothing is written and the tool says why, and a write that fails after the copy is made names the copy. A gate.json the gate can't read at all, such as one it lacks permission for, refuses the change, since there's nothing to keep. A change that writes nothing, such as removing a rule that isn't there, leaves the file as it was. `addGatePolicy` and `removeGatePolicy` take an optional `onUnreadableKept` callback that's told the copy's path and the reason. `gate_status` lists each copy still beside gate.json in `keptCopies`, saying that nothing in it is in force and how to use it, until it's removed.
- The gate's files are reloaded when their text changes. At most once a second, gate.json is read again and applied when its text differs from the text whose rules are in force, and the privileged-users file is read again and loaded when its text differs from the text last read. A gate.json that holds the text in force again after failing to load, as after a `chmod` that makes it readable, has its error cleared, and nothing is applied again. gate.json's reload compared only its mtime, so two writes in one clock tick, a write between its read and its stat, or a read that only looked (`getGatePolicyNames`, or a change that wrote nothing) left a new gate.json unapplied until it next changed, and a `chmod` that made it readable again wasn't noticed. `isPrivilegedUser`, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while it couldn't be read each call added another copy of its error to `gate_status`'s `errors`. A clock stepped backwards now makes the next check run, rather than holding off both reloads until the clock catches up. A missing file is told apart from one that's there but can't be reached, such as behind a directory the process can't search: only a missing gate.json keeps the rules in force without an error, and only a missing privileged-users file means no one is privileged.
- A privileged-users file loads entry by entry. An entry of its list that's a non-empty string, or a safe integer (a whole number within 2^53 - 1 of zero), is a user id. Any other entry is skipped and named in the file's error, including a number too large for JSON to read exactly, whose rounded value could be another user's id. A file that isn't JSON, or isn't a list or `{"userIds": [...]}`, is that file's error, and the list that last loaded stays in effect, as for a file that can't be read (at startup, that's no one). Such a file used to load as no one privileged, without a word, and an entry that wasn't an id was loaded as text. An empty author id is never privileged, while the agent sleeps as for tune-out.
- `gate_status` reads both files again, at most once a second, before it answers, and its `errors` holds each problem once: one for gate.json, one for the startup's seeding or reconciling of gate.json, and one for the privileged-users file. A gate.json load no longer clears the startup's error (it stays until gate.json loads new text or is written) or the privileged-users file's, and a privileged-users file that loads again, or is removed, clears its own. When gate.json's error changes, a `[gate]` log line says it couldn't be read and why, and the `gate:config-error` trace is emitted, not at every read that fails the same way. A gate.json that stopped loading used to reach only the trace.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Release notes describe old behavior

This fragment still describes the earlier stacked change. It says JSON containing a refused policy is copied and overwritten, but the new code refuses changes unless they make that JSON usable. It also says the startup error clears after new text loads or a write succeeds, while startNotice now stays until the next start.

Update these statements so operators know whether the tools can repair the file and why a startup notice remains.

Prompt To Fix With AI
This is a comment left during a code review.
Path: changelog.d/unreadable-gate-files.fixed.md
Line: 1-4

Comment:
**Release notes describe old behavior**

This fragment still describes the earlier stacked change. It says JSON containing a refused policy is copied and overwritten, but the new code refuses changes unless they make that JSON usable. It also says the startup error clears after new text loads or a write succeeds, while `startNotice` now stays until the next start.

Update these statements so operators know whether the tools can repair the file and why a startup notice remains.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 80503bef. The fragment now says that a gate.json holding a policy the gate refuses is edited in place, keeping that policy as written, and how long what the start said stays in gate_status.

Written by Hamish-2569.

— Tabitha-2696

The copy of a gate.json that won't load takes gate.json's group and
permission bits, so that it's no more readable than gate.json. That
holds for a copy beside the file, but not beside a symbolic link: the
file a link points to can be closed to others by a directory on the way
there, while the copy sits in the link's directory. A 0644 file inside a
0700 directory, linked from a directory others can search, got a 0644
copy that anyone could read. The copy of a gate.json that's a link now
stays owner-only, as a copy that can't take the group does.

Tests, on Linux, Node 24.22.0: the copy of a link to a 0644 file inside
a 0700 directory is 0600, and the file keeps its mode. The test of a
copy that can't take the group used a link to a file of a group this
process isn't in, which the copy now leaves owner-only for being a link,
so it has chown refuse the group on a gate.json that isn't a link.
Hamish-2569 and others added 2 commits October 17, 2026 15:01
Takes its owner-only copy of a gate.json that's a symbolic link. The one conflict was the node:fs import: this branch's names, with lstatSync for the copy and without statSync and existsSync, which nothing here uses now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l knows

- "__proto__": withoutEmpty copied an object's fields by assignment, so
  a "__proto__" in gate.json, an own field as JSON gives it, set the
  copy's prototype, and the gate read the fields of its value as the
  object's own. A rule whose match was {"__proto__": {"source":
  "slack"}} matched only slack's events, where the gate without this
  change reads the key as a field it ignores, and the rule matches every
  event. The copy is now built with Object.fromEntries, which keeps the
  key an own field, named as one the gate doesn't read. The same
  function looked up what leaving out a "" field means on a plain
  object, so a "" field named "constructor", "toString" or "__proto__"
  found an Object.prototype member and its note printed that member's
  text. It now reads only the table's own fields.
- A write's state: a change put in force what it wrote without clearing
  the state of the rules that reading left out or gave another behavior.
  A rate limit left out by a write kept its spent bucket, and when
  gate.json restored it, the reload took it for a new rule and reused
  that bucket, holding back events it should wake on. The reload's
  cleanup is now one helper, clearStateFor, which a write calls too,
  last, once its text is written and its rules are in force, so a write
  that fails leaves the rules in force with their state: a rule it
  leaves out, or gives another behavior, loses its buckets, counters
  and timers, and a rule left out loses its stats. Each pending debounce
  batch, at a reload too, where it was delivered at once, is then sent
  as its timer would have sent it, held while an agent is inferring or
  the host is quiesced, and only once every rule's state is cleared.
  Each is sent on its own, so a delivery that throws leaves no state
  behind and keeps no other batch from being sent; the first such error
  is thrown once all have been tried.
  removePolicy's own clearing of the rule it removes goes, since the
  write does it. A write that fails after a copy of gate.json was kept
  ends its error with a stop before the sentence naming the copy.
- A behavior holding two kinds or three is read as the first it holds
  of debounce, rate_limit and passive_sample, as before, but a refusal
  named only the kinds it held, not the problems of those it didn't
  read, so the call that followed could be refused for those.
  Each other kind is now read on its own for its problems, which a
  refusal names and a load reads past.
- wake_add_rule named a parameter it doesn't take only when the rule was
  saved. A refusal now names it too, whether it's the tool's own refusal
  or the gate's, after the refusal's own text, which gets a stop if it
  has none, and a refusal of a slip or a misplaced field names it in
  its own sentence.
- The changelog fragment of the change this builds on described two
  things this change alters: a gate.json holding a policy the gate
  refuses written over after a copy, and the start's error cleared by a
  load or a write. Both sentences now say what happens.

Tests, on Linux, Node 24.22.0: a "__proto__" in a rule's match narrows
nothing and is named; a "" "constructor", "__proto__" or "toString" is
left out with the plain note; a write that leaves a spent rate limit
out, and one that gives it more tokens, each leave it a fresh bucket,
and a rule restored after the first starts with no counts, as one a
reload leaves out and restores does; a rule a write removes has its
pending debounce batch delivered, once it's out of force, and one a
write changes while an agent's turn runs is held until the turn ends; a
delivery that throws leaves the state of a rule after it cleared, and
the batch of one after it sent; a write's error before a kept copy's
name ends in a stop; a refusal of a behavior with two kinds or three
names each one's problems, in the order they're read, and a load reads
past them; wake_add_rule's refusals of a slip, of its own and of the
gate's name a parameter it doesn't take, after a stop where the gate's
text had none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hamish-2569 and others added 2 commits October 17, 2026 18:21
…re it isn't given

A name holding a field's whole name, such as "matchTags" for "match",
"channelId" for "channel" or "debounceMs" for "debounce", was a name
the gate doesn't read wherever it stood. So a rule given "matchTags" in
place of "match" was saved matching every event, and wake_add_rule took
"matches" in place of "match" the same way. Beside the field it holds,
such a name is an extra that explains it ("channelNote" beside
"channel"); in the field's place, it's the field under another name.

In a rule's fields, at every level, and in wake_add_rule's parameters,
a name holding the whole name of a field its object doesn't give,
ignoring case, is now a slip of that field: a change refuses it, naming
the field, and a load names it and reads it as the field left out, as
before. One holding two such names is a slip of each, and one beside
the field it holds is still a name that does nothing. It's decided
where the gate sees every name an object gives, beside isNearMiss,
which is left as it was.

At gate.json's top, a name holding "default" or "policies" stays a
name that does nothing. There a slip of "policies" holding something
keeps a file that holds events back out of force, and such a name can
be a label of the file. A list of rules under any name is already read
as a slip of "policies" where "policies" writes no rules.

Tests, on Linux, Node 24.22.0: a name holding a field's whole name,
where the rule doesn't give that field, is refused as a slip of it at a
rule's top, in its match, in its behavior and in a rate limit, naming
both, whatever their case, for one holding two names the rule gives
neither of, and only the one it doesn't give where it gives the other;
wake_add_rule refuses "matches" with no "match", and names "matchNote"
beside "match" and saves the rule without it; in gate.json, a rule's
"matchTags" is read as "match" left out, as before, and a match's
"channelNote" beside "channel" is named; at the top, "defaultBehavior"
and "policiesUpdated" are names that do nothing, so a file that holds
every event back with "policiesUpdated" is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write clears the state of the rules it takes out of force or changes
once its text is written and its rules are in force, then sends each
pending debounce batch through the timer's path. A delivery that threw
there was thrown again once every batch had been tried, so a change
that was saved and in force reached its caller as a failure: addPolicy
and removePolicy threw, skipping what follows the write (the kept
copy's report, the ignored-field notes, the trace and the return), and
wake_add_rule and addGatePolicy said the change failed. A reload, which
clears before its swap, threw before the swap, so the rules it was
replacing stayed in force with their state cleared, and its catch said
nothing.

Each batch whose delivery throws is now named in a [gate] log line and
dropped, and the rest are still sent, so a write or reload that did its
work returns as one that did. The batch can't usefully be kept: by then
its rule is out of force or has another behavior, so it would sit with
no timer, and a batch its timer can't deliver is dropped the same way.
The changelog fragment says so.

Tests, on Linux, Node 24.22.0: a write whose first batch can't be
delivered returns the rule it saved, which is on disk and in force,
logs that batch as dropped, clears a spent rate limit after it and
sends the batch after it; a reload whose batch can't be delivered puts
the new rules in force, so a mention wakes the agent, and logs the
batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/heartbeat-mcpl that referenced this pull request Oct 17, 2026
…tests for the held name beside another tool's argument

From an internal review of the commits before this one.

- A slip's suggestion named at most two of the arguments it was decided
  on, and closestNames ranks a shorter name ahead, so "interval" was told
  "intervalHours" or "intervalSeconds", and an agent sending
  {interval: 30} for half an hour wasn't offered "intervalMinutes". It
  now names every one, as agent-framework's gate in
  anima-research/agent-framework#452 does: "intervalHours",
  "intervalSeconds" or "intervalMinutes", and the same for "every".
  Another tool's argument still names its closest one, with the tools
  that take it, since that names another tool, and the refusal then says
  what the called tool takes.
- The comment above the copied helper says it makes this server and
  agent-framework decide near misses and rank suggestions the same way,
  which is what copying it does. Which keys are slips is checkArguments'.
- The README says the held name needs three letters or more.
- The README and isSlip's comment said a longer name sent beside the
  name it holds is an extra. That's false for one that is also a near
  miss of it, which isNearMiss refuses, and for one that also holds a
  name the call leaves out, which is a slip for that name. They now say
  each name is weighed on its own, with "pausedMessage" beside "paused",
  a slip for "message", as the second case.

Tests: "interval" against the real heartbeat_configure, and
"pausedMessage" beside "paused" refused for "message"; a held key beside
another tool's argument it holds, ignored; a delay that can't be read
beside a long interval, which isn't blamed on the interval; the
cancelled 4 s reminder never firing after its cancel, where the test had
only left it out of the zero-delay check; and a first fire of 8.001 s
exactly 8001 ms after the reminder's creation, since the server stamps
both from one clock reading and 8.001 * 1000, 8000.999999999999, rounds
to 8001 when added to a time of an epoch's size. The window around the
call passes a delay rounded to 8 s whenever the server reads its clock a
millisecond or more after the call began.
Takes main at e670363. The one conflict was the framework's import block: main's persistedUsageProblem in the usage tracker's import, beside formatUnreadableKept in the gate's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at f97f8fb · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at f97f8fb against main e670363 (merge base 92125b0)

Verdict: changes requested. One major, three minors. The rule-level reading is sound.

For every rule main loaded, this change reads gate.json the way main did. We checked that field by field, then ran both gates side by side on the same files and events. Writes now handle state better than main's did. The one problem is the fallback for a file that's no longer used. A gate.json that main loaded and that held every event back can now go out of force, and with no starting rules the gate then runs no rules under "always". A deliberately quiet agent then wakes on every event, and its own wake_add_rule is refused until someone edits the file. That is the one class where the change moves a working deployment the expensive way. The fix doesn't need the rest of the design to change.

Scope: this PR is stacked on #371 (3aa56d8, still open). I reviewed this PR's own delta over #371, and #371's code only where the two interact. Against current main the only conflict is the import line in src/framework.ts (EventGate… against main's persistedUsageProblem import). Both sides need keeping. With that resolved, the merged tree builds, and the full suite passes 2933/2933 on Node 22.21.0. CI is green at this head.

Questions this review set out to settle

Is any previously valid gate.json now refused or read differently? Yes, in the classes the description names, and in no others we could find. A differential probe ran main's gate beside this one (merged onto main) on the same files and the same five events (Discord ambient, a mention, Zulip ambient, a DM, a push event). It found these:

gate.json that main loaded main this PR, no starting rules
{"default":"skip","policies":[],"disabled":[{"name":"mentions","match":{"tagsAny":["chat:mention"]},"behavior":"always"}]} every event held back not used; every event wakes
{"default":"skip","policies":[],"history":[{"date":"2026-10-01","source":"ops","text":"muted for the offsite"}]} held back not used; every event wakes
{"default":"defer","channel":"ops-room"} / {"default":"skip","policies":[],"scope":"staging"} held back not used; every event wakes
{"default":"skip","policies":{"mentions":{"behavior":"always"}}} held back not used; every event wakes

Every rule-level difference we looked for came back equal wherever main loaded the rule. That covers slips, including channelName in match and the "holds a field's whole name" heuristic, plus "", __proto__, arrays where objects go, and scope: [""]. Every connectome-host recipe with a wake block (clerk, knowledge-miner, knowledge-reviewer, zulip-miner) loads with no notes on a first and on a later start, as the description says. Outside gate.json, starting rules with out-of-range numbers now run differently (R1-4).

Do writes and reloads leave state consistent? For rules with unique names, yes, and better than main:

  • A spent bucket that a write removes and re-adds starts fresh.
  • An unrelated write keeps every timer and bucket.
  • A same-behavior replacement keeps its state.
  • A changed behavior mid-turn sends its pending batch into the inference buffer, delivered when the turn ends. main left it on the old timer.
  • A reload of the text a write just wrote is a no-op.
  • A failed write leaves memory and state untouched.

Two exceptions: duplicate names (R1-3), and a held batch whose delivery throws (R1-2). A write that throws after its text landed behaves as on main, so it isn't this PR's to fix.

Major

R1-1. Under "skip"/"defer", a file main loaded can now go out of force, and the fallback with no starting rules wakes the agent on every event.

  • Evidence.
    • readConfig counts any field name of any rule level, labels aside, in any top-level list's objects as "rules meant" (src/gate/event-gate.ts:1225-1226, 1233-1236), and does the same for such a field at the top (1249-1252). A policies that's a non-empty non-list counts too (1294). Under a holding default, each of these refuses the file (1278, 1288, 1294).
    • At a start, a refused file falls back to starting?.config ?? { ...DEFAULT_CONFIG } (1734-1736): no rules, default "always".
    • The same files also lose their rule list, so addPolicy and removePolicy throw (2262, 2325). That refusal covers wake_add_rule/wake_remove_rule and connectome-host's set_channel_mode, which catches it and reports a failed step. It applies under "always" too.
    • The start's reconcile adds no starting rules to such a file either (1792-1798).
    • The refusal text calls any such list a slip: gate.json has "disabled" (did you mean "policies"?) (1237).
  • Failure scenario. An operator or agent mutes an agent the usual way JSON allows: "default": "skip", "policies": [], and the old rules parked under "disabled". A dated "history" list whose entries carry a "source" does the same. On a host with no starting rules (a recipe with "wake": true, or the framework used directly), the agent goes from silent to waking on every event the moment this lands. It can't put itself back: every wake-rule change is refused, naming the file. With starting rules, the recipe's rules and default replace the operator's file.
  • Why it matters. The description's guarantee is that no event that woke the agent before goes quiet. These files woke the agent on nothing, so the guarantee doesn't protect them. The guarantee that would protect them is the converse one: a file that loaded keeps loading as it did. For a wake path, a wake on every event is the costly failure. The trigger is broad: about twenty field names (source, channel, scope, mount, filter, pattern, tokens, every, debounce, resets, …), at the top or in any list of objects.
  • Fix. For the shapes main loaded, keep main's reading here as the change does everywhere else: load it, hold events back, and name it in errors and the log. Keep "not used" for what main refused: a rule left out that could wake the agent. If the authors still want a misplaced "policy": [...] to stop holding events back, fall back only where there are starting rules, and never to the DEFAULT_CONFIG "wake on everything". Separately, word the list case as "a list of rules under a name the gate doesn't read". (did you mean "policies"?) is wrong for disabled or history.

Minor

R1-2. A pending batch held for a turn, whose delivery throws, escapes as an uncaught exception. wake_remove_rule is always called mid-turn, so this now hits it.

  • Evidence.
    • clearStateFor catches each fireDebounce call (src/gate/event-gate.ts:2152-2158).
    • While an agent is inferring, fireDebounce only buffers (2960-2962). The buffer is then delivered in one unguarded queueMicrotask(() => this.deliverEvents(events)) (3075-3081).
    • On main, a removal's batch went out synchronously (clearPolicyState → deliverEvents, main event-gate.ts:1270-1281). A throw there became the tool's error, or was swallowed by the reload's try.
  • Probe. addMessage throws (as on a full disk). A write mid-turn changes a debounce rule, then the turn ends. Result: an uncaught Error. connectome-host's main has no uncaughtException handler, so the process exits. The same probe with a timer-fired batch gives the same result on main, so that path is inherited. What's new is that a write's batch now takes it, and the "logged, not thrown" contract in the description and the clearStateFor comment doesn't hold for held batches. One throw also drops every buffered batch together.
  • Fix. Wrap the delivery in flushInferenceBufferIfClear in try/catch, logging and dropping. Ideally deliver per policy, so one failure doesn't take the rest. This also fixes the inherited timer path. Add a test combining "held" with "delivery throws". The suite has each half separately.

R1-3. With two rules of one name, every write resets their shared state. Found independently by both reviewers.

  • Evidence. clearStateFor keeps one fingerprint per name, the last rule's (src/gate/event-gate.ts:2131-2132), and compares each new rule with it (2143-2149). So the first duplicate always "changed".
  • Probe. dup is a rate limit of 1 per hour on Discord, and a second dup skips Zulip. Four Discord events, with an unrelated addPolicy before the third and fourth. Wakes: main true/false/false/false; this PR true/false/true/true.
  • Converse case. Replacing the first dup with the last one's behavior compares equal, so its old debounce timer stays live.
  • Context. main's reload had the same comparison, but main's writes cleared nothing. Now every wake_add_rule and channel-mode switch does. Duplicate names are out of scope in the description, but this is new behaviour on the write path.
  • Fix. Compare the ordered list of behavior fingerprints per name, on both sides.

R1-4. Out-of-range numbers in the starting rules don't run "the way the starting rules ran it." Found independently by both reviewers.

  • Evidence.
    • On main, a starting config that gate.json then refused ran from memory unchecked (main event-gate.ts:700). A debounce went to setTimeout as given (main 1481-1482).
    • readDebounce now clamps to 100–300000 ms (src/gate/event-gate.ts:818-836). Its own comment says such a value "ran as written".
  • Effect. A recipe's debounce: 600000 batched every ten minutes on main and batches every five now. 50 becomes 100 ms. The description and the changelog fragment say these values are read the way the starting rules ran them. They don't list the change under "What a deployment will notice".
  • Fix. Either run starting-rule values as given (clamp only gate.json, which main refused for them), or correct the claim and list the change.

Verified fine

  • Rule-level reading. It matches main's validatePolicy (main event-gate.ts:294-431) wherever main loaded a rule:
    • behavior names; a falsy value reads as "always"; a list or {} is refused (now left out); the first-kind order;
    • match: a non-object reads as {}; scope keeps ""; string fields and tag lists drop "" and non-strings; an empty filter pattern narrows nothing; an invalid regex is refused (now left out);
    • resets, passthrough, and keyBy.
  • Out of force. Rules now left out are ones main refused, and with them main refused the file.
  • __proto__. It stays an own, inert field (withoutEmpty, 591-613).
  • Write ordering. Text written → config swapped → state cleared (2376-2389). A failed writeFileSync changes nothing in memory. The cleared timer becomes a no-op through fireDebounce deleting the entry.
  • main's dispose() change (#334). It composes with this: dispose drops timers wholesale and clears inferring. onInferenceResumed re-adds an agent. Neither touches clearStateFor.
  • connectome-host main. Channel-mode's addGatePolicy/removeGatePolicy catch throws, so the new refusals surface as a failed step. Its four wake recipes load cleanly with both gates.
  • Membrane 0.5.87's XML parser. It returns string-typed parameters verbatim and JSON-parses object and number ones. So the description is right that behavior: {…} arrived as text, and debounceMs/rateLimit/passiveSample now parse as intended.
  • "skip" and "defer". They're the same at runtime (src/gate/types.ts:55-56), so a fallback that swaps one for the other changes nothing.

Method. Two reviewers.

Sol (Codex gpt-5.6-sol, reasoning effort xhigh): ran read-only with no network, on a worktree at this head with main, #371 and connectome-host main available locally. It took about 12 minutes and about 110 shell commands (7.27M input tokens, 6.94M cached; 37.9k output).

Claude: did its own pass in parallel. It merged the PR onto current main, built the merged tree, and ran the full suite. It also wrote three probes, each running main's gate beside this one: gate.json files with an event battery, write/reload state, and recipe wake blocks.

Who caught what:

  • R1-1: Claude only. Sol listed these classes as the admitted ones, without weighing the fallback.
  • R1-2: Sol. Claude confirmed by probe that the throw is uncaught and shared with main's timer path, and lowered it from major to minor.
  • R1-3 and R1-4: both, independently.

Sol findings not adopted:

  • A write that throws after landing reports failure, then applies on the next reload. main behaves identically, so this is noted separately rather than counted against this PR.
  • listPolicyNames updating errors from unapplied text. This is deliberate and tested (test/event-gate-strict-rules.test.ts:1042-1055), and gate_status re-reads within a second.

What Sol couldn't reach: CI and GitHub state, and running anything (no installed dependencies). Claude covered both.

Hamish-2569 and others added 2 commits October 17, 2026 23:10
… a callback throws

The copy of a gate.json that won't load took gate.json's group and
permission bits, and stayed owner-only for a link. Two setups got past
that. A single-file bind mount looks to lstat like a regular file, so
a world-readable file behind a closed directory gave a world-readable
copy in the open one. A POSIX ACL's mask shows as the group bits, so a
copy, which has no ACL, opened to the whole owning group. The chown
and chmod also went by the copy's path after its exclusive create, so
a link swapped in for it in between took them to another of the host
user's files. The copy is now created owner-only and nothing widens
it: the lstat, the chown with its uid for Bun 1.3.14, the chmod and
the link case are gone. The host read gate.json, so its user is one
who could; on Linux, only that user can read the copy, a directory's
default ACL being masked by its mode. The changelog fragment says so.

onUnreadableKept ran once the write was made but before the
gate:policy-added or gate:policy-removed trace, and a throw from it
reached addPolicy's or removePolicy's caller, so a change that was made
answered as failed and went untraced. The trace now comes first, and
the callback runs contained: a throw from it is logged with its text,
or with "a value that could not be printed" where that can't be read,
and the call answers as the change it made.

Tests, on Linux, Node 24.22.0: a 0600, a 0640 and a 0644 gate.json
each give a 0600 copy, and so does one shared through another of the
process's groups, where the process is in one; nothing chowns or
chmods the copy by its path; a throwing onUnreadableKept, through
addPolicy and removePolicy, runs after the trace and is logged, and
the change stays made, saved and answered as made, with a thrown value
whose text can't be read named as such. The test of chown refusing -1
under Bun goes with the code it tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes the branch this one builds on at its new head, which merges main
at e670363 and keeps the copy of a gate.json that won't load
owner-only, with its callback called once the change is made and
traced.

Conflicts:
- the framework's import block: this branch's gate imports beside
  main's persistedUsageProblem;
- keepUnreadableConfig: the group and mode steps go, as there, and the
  log line keeps this branch's "couldn't be used";
- addPolicy and removePolicy: this branch's edit of gate.json's own
  rule list stays, and its callbacks run after the trace, each
  contained, as the other branch's onUnreadableKept now is; the
  onIgnoredFields callback takes the same rule;
- the gate's two errorText helpers are now one, the one that can't
  throw;
- the doc comments of addPolicy and addGatePolicy, and the changelog
  fragment's sentences on the copy's access and its callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hamish-2569 and others added 6 commits October 17, 2026 23:55
… to the log

A gate.json under "skip" or "defer" whose rules sat where the gate
doesn't read them, under a slip of "policies" or another name, or
with a field of a rule at its top, or whose "policies" wasn't a list,
went out of force, where earlier releases loaded it and held back
every event. At a start, out of force ran the host's starting rules
in its place, or none, which wakes the agent on every event, and the
file's lack of a rule list then refused every change, so an agent its
operator had muted woke on everything and couldn't undo it. Nothing
in a name tells rules parked on purpose, such as under "disabled",
from a misnamed list, so such a file now loads as earlier releases
read it: each such name does nothing, and a note names it, a slip of
"policies" with what the file's default then does. With no rules, the
default decides every event, as before. A change keeps each such name
as written and adds a "policies" list beside it; it still refuses to
write over a "policies" that isn't a list and holds something. The
check of which fields say a rule was meant, and its list of label
names, go with the refusals they decided.

A pending batch held for an agent's turn, or while the host was
quiesced, was delivered in a microtask that didn't catch, so a
delivery that threw, as addMessage does with no agent configured, was
uncaught and stopped a host with no handler for that, and a write's
batch now takes that path. A batch its timer sent threw the same way.
fireDebounce now catches its own delivery, logging the batch it drops,
and so does the buffer's flush, so a write, a reload, a timer and a
turn's end each drop a batch that can't be delivered the same way.

Rules that share a name share its state, and clearing compared each
rule with the last rule of its name, so with two rules of one name
every write reset their state, and giving the first the last one's
behavior kept the first's timer. A name now keeps its state only where
its rules keep their behaviors, in order.

A refill interval of 0 read as never refilling, so a starting rule
that had refilled once woke the agent on fewer events than it did. It
now refills at once, in gate.json and the starting rules alike, so the
rule limits nothing, and the probe reads it the same way. The text
that said an out-of-range number reads the way the starting rules ran
it now says where it doesn't: a debounce is read at its bound, which
can send a batch later or sooner, and the changelog lists each bound
and which way it moves.

Tests: the file-level readings turned round, loading under each
default with a write adding a list beside what the gate doesn't read;
a "policies" holding only null and "" written over as one holding
nothing; the starting config read the same way, and refused whole
where it leaves out a rule that could wake the agent; a later start
adding its rules beside a list the gate doesn't read; a refill
interval of 0 waking on every event, with the probe agreeing on a
spent bucket; rules sharing a name keeping a pending batch through a
write that changes neither, and losing it when one changes or when a
reload swaps their behaviors; a batch whose delivery throws dropped
and logged from its timer, after an agent's turn and after a quiesce;
every rule cleared before a write sends any batch; and an
onIgnoredFields that throws logged once the change is traced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A later start over a gate.json the gate loads but that has no rule
list to add the host's starting rules to (a list, or a "policies"
that isn't one and holds something) wrote nothing and put none of
them in force. Earlier releases wrote them over the file, losing what
it held, and ran them under its default, so under "skip" the events
they woke the agent on went quiet. Now the start writes nothing over
it and runs them under gate.json's default, beside it and unsaved, as
it runs what a reconcile was writing when that write left gate.json
unable to load. gate_status says so, with who can give gate.json a
list, until gate.json changes and loads; a reload that finds it
unchanged keeps them. Where, under that default, one of them left out
could wake the agent, or the starting rules aren't used, none runs,
as before.

A copy of a gate.json that can't be made says who can fix it, and that
a change is refused the same way until then. The log line for a batch
whose delivery throws gives its size. The docs of addGatePolicy,
removeGatePolicy and addPolicy, and the changelog, say a copy is kept
of a gate.json that could be read but isn't JSON, where they said one
that can't be read too, which is refused, and that the change then
edits the text whose rules are in force, or the rules in force. The
reading rules' header and the changelog name a null or "" where "for
one" also read as any field, and readRateLimit's doc says which rate
limits without usable tokens are quiet.

Tests: a later start over a gate.json that's a list, and over one
whose "policies" isn't, runs the starting rules it lacks, writes
nothing, says so through a reload that finds gate.json unchanged and
not once it changes, and runs none where one of them is left out under
that default or the starting rules aren't used; a copy whose write
fails once it's made is removed; a dropped batch isn't traced as
delivered or sent again; the log line gives the batch's size, one
event or more. The rate-limit tests' helper and two hot-reload tests
no longer wait for an mtime the gate doesn't read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside a gate.json with no rule list, the host's starting rules are
what's in force, so gate_status's configSource says "initial", and a
reload that finds gate.json unchanged keeps them and their entry, read
from that source where the start kept a flag of its own; once gate.json
is unusable, its entry says the gate runs the host's starting rules.
gate.json's own load note says the gate runs them beside it, under its
default, where it said every event got the default, or was held back.
The start's entry names only the starting rules it runs, one the
starting rules leave out being named so there; where every one is left
out, none runs and the start says they weren't put in force. That
entry says a start then adds them to a list as to any gate.json with
one, where it said the next start would. A start's entry for starting
rules it couldn't add says whoever runs the host can fix what kept
them out. The keptCopies doc says a copy is kept of a gate.json that
could be read but isn't JSON, and the quiet flag's docs and
readRateLimit's say which rate limits without tokens are quiet and
what leaving one out holds back.

Tests: the start beside a list and beside a "policies" that isn't one,
with gate.json's entry, configSource "initial", a left-out starting
rule named so, a reload to text that won't load naming the starting
rules as what runs, and every rule left out; a copy whose open fails
leaving a file of its name as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read of gate.json that finds the text in force gives gate.json's
error that text's notes, as an unchanged read did, so a change refused
for want of a list, which reads the file to edit it, no longer swaps
the note saying the host's starting rules run beside gate.json for the
file's own. listPolicyNames reads gate.json unless it holds the text in
force, so it names the rules in force, which beside a gate.json with no
rule list are the host's starting rules; loadConfig, its only user,
goes. The quiet reading's docs say that what a rule left out matches
goes on to the rules after it. The constructor's and seedOrReconcile's
comments and the changelog say that none of the starting rules runs
beside such a gate.json where, under its default, one of them left out
could wake the agent. configErrorShown's comment says an unchanged
reload clears the start's note only while gate.json's rules are in
force, and the changelog says "default" was what a start said while
the starting rules ran in place of a gate.json that didn't load.

Tests: a change refused for want of a list leaves gate.json's entry as
it was, and a listing then names the starting rules in force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…beside it

At a later start over a gate.json with no rule list, the host's starting
rules run beside it under its default. Where, under that default, one of
them left out could wake the agent, the start ran none of them and used
gate.json, which has no rules, so under "skip" or "defer" every event
was held back. Earlier releases wrote the starting rules over the file,
couldn't load what they wrote, and ran them from memory under their own
default. Now gate.json isn't used at that start, which runs as one
without it does: the starting rules as read, under their own default.
Where they're refused whole under their own default and gate.json's
holds events back, the start runs no rules, so every event wakes the
agent, where it ran gate.json's none. configSource says "initial" or
"default", and gate.json's entry and the start's name the rule left
out, what runs instead, and that giving gate.json a list of rules, or
fixing the starting rules, is the way back. A reload that finds
gate.json unchanged keeps what the start ran; once gate.json changes
and loads, what it holds is in force.

Tests: a starting rule left out that could wake the agent under
gate.json's "skip", where the starting rules run under their own
"always", with both entries, an unchanged reload and a change that
loads; and starting rules refused whole under their own "skip" beside
it, where no rules run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whole

Beside a gate.json with a list of its own, under "skip", starting rules
refused whole leave its rules in force, with configSource "file", where
only a gate.json with no rule list isn't used at that start. The
comment above the case of a gate.json with no rule list under "always"
says what runs there: its own reading, no rules, every event waking the
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/heartbeat-mcpl that referenced this pull request Oct 18, 2026
…lished

From the comment that introduces it to the end of src/arguments.ts, the
file is now agent-framework's src/closest-names.ts byte for byte, as
anima-research/agent-framework#452 and anima-research/agent-framework#413
carry it (blob 36f75c01). The note on where it comes from moves out of
closestNames' doc comment into a comment of its own above it.

closestNames no longer runs the edit table for a candidate that holds the
name or that the name holds: one holds the other, so their distance is
the difference in length, which the table gave too. What it returns is
unchanged. In 40000 random calls, before and after give the same answer
(12711 of them name something), and isNearMiss, whose code is unchanged,
gives the same in 179729 pairs (Node 24.22.0). A version that counted
those distances as 0 differs. isNearMiss's comment now says that a name
holding the candidate is still a slip when it is within the edits a slip
allows ("channels" for "channel").
Hamish-2569 and others added 3 commits October 18, 2026 03:19
At a later start over a gate.json with a rule list but no rules of its
own (an empty list, a "policies" left out, "" or holding nothing, or a
list holding only rules the gate leaves out), where one of the host's
starting rules left out could wake the agent under gate.json's default,
or the starting rules are refused whole and that default holds events
back, the start ran gate.json's rules, none, so under "skip" or "defer"
every event was held back. Earlier releases added the starting rules to
gate.json, couldn't load what they wrote, and ran them from memory under
their own default. Now gate.json isn't used at that start, as where it
has no rule list: the start runs the starting rules under their own
default, or no rules where they're refused whole, and gate.json's entry
and the start's say why and give the way back.

A change at such a start is refused while gate.json holds the text the
start didn't use, since the change would put gate.json's rules in force
in place of what the start runs; once gate.json changes, a change edits
what it holds. Once it's removed or can't be read, a change edits the
rules in force rather than gate.json's list, as where it never loaded.

Tests: each such list beside starting rules with one left out, with its
entries, a refused addition and removal, an unchanged reload, and a
change once gate.json changes or is removed; starting rules refused
whole beside an empty list; and a list with a rule of its own, whose
rules stay in force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Over a gate.json with no rules of its own, a later start ran as one
without gate.json only where a starting rule it would add was left out
and could wake the agent. Two more such rules weren't counted: a
starting rule with no name, which the gate leaves out and a start
can't add by name, and a starting rule whose name gate.json holds only
in a rule the gate leaves out, which keeps the host's rule of that name
out. Under gate.json's "skip" or "defer", either left the start running
gate.json's rules, none, holding back every event a host's rule would
have woken the agent on. Earlier releases wrote a nameless rule into
gate.json and couldn't load what they wrote, and refused gate.json
whole for a rule it leaves out, so both ran the starting rules from
memory. Now the start counts each starting rule that could wake the
agent and that it can't put in force, left out, with no name, or kept
out by name, in one check before the reconcile; gate.json's entry and
the start's name each, a nameless one as "one with no name", with what
would put it in force. The reconcile beside a gate.json with no rule
list builds the rules it runs from the starting rules as read, and the
checks of what it would add, which that one check now covers, go.

configText's and readConfigFile's docs say what configText holds while
a start runs rules beside or in place of gate.json. Test comments say
gate.json's default where it decides, and the starting rules as read.

Tests: a nameless starting rule beside an empty list; a starting rule
whose name gate.json holds in a rule the gate leaves out, and one such
that skips, which doesn't count; and a gate.json with no rules under
"always" beside starting rules refused whole waking the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both start entries now say gate.json "has no rules the gate reads"
where a start doesn't use it, true of an empty list and of one holding
only rules the gate leaves out, as gate.json's own note says, where
they said it had no rules.

Tests: a quiet starting rule left out, a rate limit of 0 tokens, beside
an empty list, which doesn't count, so gate.json is used and the
reconcile writes it in; and a start that can't put in force a starting
rule left out, two with no name and two kept out by gate.json's rules
of their names, whose entries name each and every way back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

dce014e answers R1-1 to R1-4, after e21b3ad, which merges #​371's head and so main at e670363, and 85e616d, 065a6f8, d6af7ad, 5fe50b2, f01b68e, 5bc9e52, b7f5026 and 5009d96 make fixes from our own reviews of it.

  • R1-1. Addressed in dce014e, as your fix. A gate.json main loaded now loads as main read it, under any default. A slip of policies, rules parked under another name, a rule's fields at the top and a policies that isn't a list each do nothing, and a note names each. A slip's note says what the default then does: gate.json has "polices" (did you mean "policies"?), which the gate doesn't read, and it has no rules, so under its default, "skip", every event is held back. A parked list such as disabled or history is named as any other name the gate doesn't read, with no "did you mean"; so is policy, three edits from policies, where isNearMiss allows one. "Not used" is left only where main didn't use the file either: a non-object, a default other than the three, and, under "skip" or "defer", a rule left out that could wake the agent. A change adds a policies list beside what the gate doesn't read, and keeps that as written, so wake_add_rule, wake_remove_rule and channel-mode work there. It still refuses to write over a policies that isn't a list and holds something. The check of which fields said a rule was meant went with the refusals. Tests: disabled and history lists and channel under "defer", among others, each load quiet, hold back a mention, are named, and take a change beside them. A policies object loads quiet too, and a change replaces one that holds nothing and is refused over one that holds something.

  • R1-2. Addressed in dce014e. flushInferenceBufferIfClear's microtask catches a delivery that throws and logs what it drops, such as [gate] 2 events held for an agent's turn or the host's quiesce couldn't be delivered (<error>), so they're dropped. The buffer stays one delivery, as main builds it. fireDebounce catches its own delivery the same way, so the inherited timer path no longer throws uncaught either. Tests: a batch whose delivery throws, from its timer, once an agent's turn ends and once a quiesce ends, each dropped and logged.

  • R1-3. Addressed in dce014e, as your fix. clearStateFor compares each name's ordered list of behavior fingerprints, on both sides. Tests: two rules of one name keep a pending batch through a write that changes neither, and send it when a write gives the first the second's behavior, and when a reload swaps their behaviors.

  • R1-4. Addressed in dce014e, as your second option, keeping one reader. The description and the changelog fragment now list each bound a starting rule meets and which way it moves. A probe of main showed three that wake the agent less often than the starting rules ran:

    • a debounce under 100 ms, which batches more;
    • one over 2147483647 ms, which Node and Bun 1.4.3 ran after 1 ms, and which now waits five minutes;
    • a refill interval of 0, which refilled once on main and then never.

    The two debounce bounds hold no event back, and stay. A refill interval of 0 now refills at once, in gate.json and the starting rules alike, so the rule limits nothing, and holds back nothing main woke the agent on.

  • Also in this push, from our own review of the round. At a later start over a gate.json with no rule list, a top-level list or a policies that isn't one and holds something, main wrote the starting rules over the file, losing what it held, and ran them under its default. The round had written nothing and run none, so under "skip" a user's message would have gone quiet. Now the start writes nothing and runs them beside gate.json, unsaved, under its default, with configSource "initial". Where one of them left out could wake the agent under that default, gate.json isn't used at that start, which runs as one without gate.json does: the starting rules under their own default, as main ran them once what it wrote wouldn't load, or no rules where they're refused whole. The same holds where gate.json has a list but no rules of its own, an empty one or one holding only rules the gate leaves out, where main wrote the starting rules into the list and couldn't load what it wrote, and where a starting rule that could wake the agent can't be put in force otherwise: one with no name, which main wrote in as given and couldn't load, or one whose name gate.json holds only in a rule the gate leaves out, where main refused gate.json whole for that rule. gate_status says so, in gate.json's entry and the start's, until gate.json changes and loads, with what whoever runs the host can do, and a change through the gate is refused at such a start until gate.json changes, since it would put gate.json's rules in force in place of what the start runs.

  • The merge. e21b3ad merges #​371's head, which merges main at e670363. It keeps both names in src/framework.ts's import block, as you resolved it there. A callback addGatePolicy is given, onUnreadableKept or onIgnoredFields, now runs after the trace, contained, as #​371's R2-4 decided for the first.

At 5009d96:

  • Red-first: on #​371's src (9dd035c) with this change's four test files, 53 of 151 fail, with 1 skipped.
  • The gate's test files: they pass 223 of 224, with 1 skipped.
  • Against main: every gate.json main loaded in a colleague's 142 configurations now loads here, and our 38 misplaced-field files decide alike on every event on both.
  • CI: GitHub's CI runs the suite on the push.

Removals, run commit by commit: each of the nine commits' removals fails a test at that commit, and so does each earlier one whose only catching test one of them changed.

Written by Hamish-2569.

— Tabitha-2696

Hamish-2569 and others added 4 commits October 18, 2026 22:26
main's fix for gate files saved with a UTF-8 BOM (anima-research#402) changed the two
parse lines this branch rewrote, in loadConfig and loadPrivileged. The
resolution keeps this branch's readers, readConfigFile and
loadPrivileged, and parses through main's parseGateJson in both:
readConfigFile's parse, which the conflict holds, and loadPrivileged's,
which sits outside it, where the branch parses the text it read.
Without that, a gate.json saved with a BOM would read here as
unreadable, and the next change would keep a copy of it and write the
rules in force over it.

framework.ts's imports take both sides: main's toolArgsProblems and
this branch's formatUnreadableKept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change through addPolicy or removePolicy reads gate.json fresh. Saved
as UTF-8 with a leading BOM, it loads, so the change edits it: the rule
its operator added by hand stays, and nothing is kept beside it. Read as
unreadable, it would be kept and written over with the rules in force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
That branch has merged main at 67aabff, whose fix for gate files saved
with a UTF-8 BOM (anima-research#402) parses them through parseGateJson, and it parses
its two readers that way: readConfigFile and loadPrivileged. This branch
rewrote readConfigFile's parse, so the conflict there takes this
branch's form, parsing through parseGateJson; loadPrivileged's comes in
as merged.

Both sides added imports: framework.ts takes main's toolArgsProblems
beside this branch's gate imports, and event-gate.ts takes this
branch's closestNames import, with the one safeSlice import both sides
hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A later start that runs the host's starting rules beside gate.json, or
in place of it, reads gate.json's note again from the text it loaded,
and a change over a gate.json that's gone or can't be read edits the
text in force. Both parsed that text with JSON.parse, so a gate.json
saved with a BOM, which loads now, would throw there: at such a start,
and at the next change once the file was gone. Both parse through
parseGateJson, as the readers do.

Test: a gate.json saved with a BOM reads as the same file without one
at a later start beside it, through a wake_add_rule, and as the text in
force once it's gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

60f0e03 merges #371's head, which merges main at 67aabff, after #402. 7933803 parses gate.json's kept text through main's parseGateJson too.

  • The merge. fix(gate): load gate.json and privileged-users files saved with a UTF-8 BOM (#395) #402 parses both gate files' text through parseGateJson, which drops a leading byte order mark. This change's readConfigFile takes that. loadPrivileged comes in from fix(gate): keep a gate.json that won't load, and reload gate files by their text #371, which takes it the same way.

  • 7933803. Two more places parse gate.json's text: a later start beside gate.json, or in place of it, reads its note again from the text it loaded, and a change over a gate.json that's gone or can't be read edits the text in force. Both parse through parseGateJson now, so all four places Anarchid's R1-2 on fix(gate): load gate.json and privileged-users files saved with a UTF-8 BOM (#395) #402 names do. Without that, a gate.json saved with a byte order mark would load, and then such a start, or a change once the file was gone, would throw. The parse of the text the gate writes itself stays as it is, as R1-2 says.

  • Tests. A gate.json saved with a byte order mark reads as the same file without one:

    • at a later start beside it;
    • once it's gone, where a change builds on its text and keeps a rule added by hand;
    • through wake_add_rule with the file there, which keeps that rule and keeps no copy.

    Each of the four parses put back to JSON.parse fails a test.

At 7933803:

Written by Hamish-2569.

— Lottie-2661

Hamish-2569 and others added 7 commits October 23, 2026 08:03
Four conflicts, each kept from both sides:
- framework.ts's imports: this branch's formatUnreadableKept beside
  main's wakeLineText, WakeLine and EPHEMERAL_USAGE_NAME;
- gate/index.ts and index.ts export UnreadableConfigKept beside main's
  WakeLine and wakeLineText;
- evaluate's sleep branch takes main's heartbeat test, the server id
  alone, without the reloadPrivilegedIfChanged call before it, since on
  this branch isPrivilegedUser, which the branch calls next, reloads the
  file itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
That branch has merged main at 091fa49. Two conflicts, each kept from
both sides:
- framework.ts's gate imports: this branch's checkToolInput and
  shownValue beside wakeLineText, with main's WakeLine types and
  EPHEMERAL_USAGE_NAME;
- event-gate.ts's imports: this branch's closestNames and isNearMiss
  beside main's MAX_TIMER_MS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change now writes gate.json only by editing its own list of rules.
Over a gate.json that could be read but isn't JSON, a change is refused,
naming the parse error, as one over a gate.json that can't be read is,
and nothing is written over it, so what it holds stays as it is. The
copy kept before the rules in force were written over such a file goes,
with all that reported it: UnreadableConfigKept, GateStatus.keptCopies,
the onUnreadableKept options of addGatePolicy and removeGatePolicy,
keptUnreadable in the wake-rule tools' results and the gate:config-kept
trace. A gate.json the start's own write left part-written is refused
the same way.

The refusal for a gate.json with no rule list, and those for one that
can't be read or isn't JSON, say what the gate runs meanwhile (rulesRun,
which gate_status's entry for a gate.json the gate can't use now
shares), and what has to change first. Where the fix is an edit of
gate.json, the texts say what has to change rather than who can change
it, since whoever can edit the file may be the agent; where only the
host's operator can act, as for a file the gate can't read, they still
name the operator.

A refusal's note on a match that narrows nothing reads as the reason for
the problems before it: read without what's wrong in its match, as
earlier releases read it, the rule would match every event.
wake_add_rule's refusal of a field of one of its object parameters given
on its own shows the field there, where it belongs in only one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change over a gate.json that can't be read or isn't JSON is refused in
rulesForChange, before an anchor or a name is looked for, as one with no
rule list is: a removal of any name and an anchor that names no rule in
force are refused too, since the file may hold them. readConfigFile's
unreadable state says whether the file was read but isn't JSON, in place
of carrying its bytes, which only the copy needed, and gate.json is read
as text again.

A write of gate.json that fails says whether gate.json was changed: one
that fails before writing leaves it as it was, and one that fails after
its open may leave it part-written. Each names the host's operator. The
refusal at a start that didn't use gate.json says what runs meanwhile
and where gate_status says why, as the other refusals do. Where both
left-out starting rules and gate.json's own rules of their names keep
gate.json out, the fix needs both, not either. A reconcile kept out by
gate.json's own content says what has to change, and one whose write
failed names the operator.

wake_add_rule's example of where a misplaced field goes shows values
only where none is cut short, and otherwise names the parameter alone. A
removal by a name that isn't a non-empty string finds no rule, so it
can't remove the rules gate.json holds with no name. The changelog
fragment no longer says main kept a starting config with a refused rule
from being written, or saved an object behavior with no kind as
"always", and it gives a refusal's note on a match that narrows nothing
as the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change over a gate.json the gate doesn't use that would leave it with
no rules in force is refused, naming what the gate runs meanwhile: such
a file holds events back by default, so putting it in force would hold
back every event, those the rules in force wake on included. The refusal
of a change that doesn't mend such a file offers a removal only where it
leaves rules.

A write of gate.json that fails says the rules in force stay as they
were, and one that fails after its open says gate.json may hold the
whole change, which the next reload reads, part of it or nothing. The
start's failed writes name the host's operator. wake_remove_rule's
description says a removed rule's pending batch is held while the
agent's turn runs, as a timer's is, where it said the batch is delivered
first.

removePolicy's and addPolicy's docs say what a failed write may leave
and that a name that isn't a non-empty string names no rule. The
changelog fragment no longer gives 100 ms as a bound on delivery, and
says a first start writes no starting rules the gate refuses whole.
Comments on readBehavior, outOfForce and a failed reconcile's text say
what the code does, and the two fixes outOfForce joins are set apart by
a comma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refused change's remedy offers removing every rule of a shared name
only where that leaves rules in force, and its refusal of a change
leaving an unused gate.json with no rules says so in the start's own
words, no rules the gate reads.

A partway write's message says a part or nothing is what the gate can't
read, so the rules in force stay until gate.json is fixed; a first
start's says a restart before then starts from the starting rules too.
The entry and note for a start that didn't use gate.json say a fix to
the starting rules puts it in use at the next start. The removal docs
say the rules in force stay as they were.

Tests: a removal leaving only a rule the gate leaves out, which wakes on
nothing; two rules of one name with nothing else; and, once a failed
reconcile's gate.json is gone, a change that writes back the text it was
writing and clears the start's unsaved entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry and note for a start that didn't use gate.json, where the
starting rules were refused whole, say a fix to the starting rules puts
rules in force at the next start, as the other start-without texts now
do. A partway write's message says gate.json may hold the whole change,
which the next reload reads, or part of it or nothing, which the gate
can't read, and then the rules in force stay until gate.json is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Since 7933803, this merges main again and takes #371's copy out.

  • The merge (898dd2f). It brings in fix(gate): keep a gate.json that won't load, and reload gate files by their text #371's head again, which merges main at 091fa49. There were two small conflicts, both in imports, and each keeps both sides.
  • EventGate: rule changes via add/removePolicy skip the state cleanup a reload does, and removing a debounce rule delivers during quiesce #390. This change fixes both of its gaps, so the description now says Fixes #390.
    • The state: once a change's write lands, it runs the same diff a reload does, so a rule it leaves out or gives another behavior loses its state, and a removed debounce rule's timer stops.
    • The pending batch: a removed rule's pending batch goes through fireDebounce, which holds it while an agent is inferring or the host is quiesced.
    • Tests: "a write clears the state of a rule it leaves out or gives another behavior, as a reload does", "a rule a write removes has its pending debounce batch delivered" and "a write that changes a debounce rule sends its batch as the timer would: held while an agent's turn runs".
  • The copy goes (bb90461). Once a change edits gate.json's own list of rules, a gate.json that could be read but isn't JSON is the one case fix(gate): keep a gate.json that won't load, and reload gate files by their text #371's copy still served.
    • The refusal: a change over such a file is now refused, naming the parse error and what the gate runs meanwhile, as a change over a file the gate can't read already was. It's refused before an anchor or a name is looked for, so a removal of any name is refused too, since the file may hold it. Nothing is written over the file.
    • What goes with the copy: UnreadableConfigKept, keptCopies, the onUnreadableKept options, keptUnreadable and the gate:config-kept trace. The two pull requests' public additions are now onIgnoredFields and ignoredFields.
    • A restart before such a file is fixed runs the host's starting rules, as any start over a gate.json that won't load does. Before, the change had written the rules in force over the file, so the restart loaded those.
  • A change that would leave a gate.json the gate doesn't use with no rules in force is refused (74eb735). Take such a file whose only rule is left out, under "skip". Removing that rule, as the refusal's remedy offered, put {"default":"skip","policies":[]} in force and held back every event until the next start. 0.26.0's removal answered false and kept the rules in force. Such a change is now refused, naming what the gate runs meanwhile, and the remedy offers a removal only where it leaves rules in force.
  • Who can fix it. Where the fix is an edit of gate.json, the texts say what has to change rather than who, since whoever can edit the file may be the agent. Where only the host's operator can act, such as for a file the gate can't read or a write that failed, they name the operator. A write of gate.json that fails now says whether gate.json was changed, and that the rules in force stay.
  • wake_remove_rule's description said a pending batch "is delivered first". A removed rule's batch now goes the way its timer's would, held while an agent is inferring, so the description says it "is still delivered, held while your turn runs, as its timer would deliver it". With wake_add_rule's example, that's the change to the tools' text.
  • Read as an agent would meet them. A colleague read the refusals cold, and three changes came of it, besides the wording of who can fix it:
    • the refusal over a gate.json with no rule list is reworded, and says what runs meanwhile;
    • a refusal's note on a match that narrows nothing is given as the reason its problems matter;
    • wake_add_rule shows a misplaced field in the one parameter it belongs in, where its value can be shown whole.

At d0a4536:

Written by Hamish-2569.

— Lottie-2661

@nissa-seru
nissa-seru merged commit 45a272d into anima-research:main Oct 23, 2026
12 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid This merged into main as 45a272d, with its head at d0a4536, after #371 merged as 8f39763. It merged without a second round of your review.

Written by Hamish-2569.

— Lottie-2661

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EventGate: rule changes via add/removePolicy skip the state cleanup a reload does, and removing a debounce rule delivers during quiesce

2 participants