Repository navigation
fix(gate): name every problem a wake rule has, and read gate.json's as it ran - #452
Conversation
… 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>
[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
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.SummaryAdds detailed wake-rule checks, preserves untouched file content during rule changes, and reloads gate files by their text.
Reviews (1) · Last reviewed commit: "fix(gate): name every problem a wake rul..." · Reviewed by Greptile |
| function withoutEmpty( | ||
| where: string, obj: Record<string, unknown>, notes: string[], means: Readonly<Record<string, string>> = {}, | ||
| ): Record<string, unknown> { | ||
| const out: Record<string, unknown> = {}; |
There was a problem hiding this comment.
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.
| 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.There was a problem hiding this comment.
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
| this.configText = text; | ||
| this.configNote = loadNote(read.sentences); | ||
| this.config = read.config; | ||
| this.compiledPolicies = this.compilePolicies(read.config); |
There was a problem hiding this 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
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.There was a problem hiding this comment.
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
| chownSync(kept, process.geteuid?.() ?? -1, gateFile.gid); | ||
| chmodSync(kept, gateFile.mode & 0o777); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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
| 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 }; |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
| 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' }, |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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
| - 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. |
There was a problem hiding this 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.
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!
There was a problem hiding this comment.
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.
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>
…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>
…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
left a comment
There was a problem hiding this comment.
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.
mainleft 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.
readConfigcounts 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). Apoliciesthat'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
addPolicyandremovePolicythrow (2262,2325). That refusal coverswake_add_rule/wake_remove_ruleand connectome-host'sset_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
mainloaded, keepmain's reading here as the change does everywhere else: load it, hold events back, and name it inerrorsand the log. Keep "not used" for whatmainrefused: 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 theDEFAULT_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 fordisabledorhistory.
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.
clearStateForcatches eachfireDebouncecall (src/gate/event-gate.ts:2152-2158).- While an agent is inferring,
fireDebounceonly buffers (2960-2962). The buffer is then delivered in one unguardedqueueMicrotask(() => this.deliverEvents(events))(3075-3081). - On
main, a removal's batch went out synchronously (clearPolicyState→deliverEvents, mainevent-gate.ts:1270-1281). A throw there became the tool's error, or was swallowed by the reload'stry.
- Probe.
addMessagethrows (as on a full disk). A write mid-turn changes a debounce rule, then the turn ends. Result: an uncaughtError. connectome-host'smainhas nouncaughtExceptionhandler, so the process exits. The same probe with a timer-fired batch gives the same result onmain, 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 theclearStateForcomment doesn't hold for held batches. One throw also drops every buffered batch together. - Fix. Wrap the delivery in
flushInferenceBufferIfClearin 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.
clearStateForkeeps 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.
dupis a rate limit of 1 per hour on Discord, and a seconddupskips Zulip. Four Discord events, with an unrelatedaddPolicybefore the third and fourth. Wakes:maintrue/false/false/false; this PR true/false/true/true. - Converse case. Replacing the first
dupwith the last one's behavior compares equal, so its old debounce timer stays live. - Context.
main's reload had the same comparison, butmain's writes cleared nothing. Now everywake_add_ruleand 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 (mainevent-gate.ts:700). A debounce went tosetTimeoutas given (main1481-1482). readDebouncenow clamps to 100–300000 ms (src/gate/event-gate.ts:818-836). Its own comment says such a value "ran as written".
- On
- Effect. A recipe's
debounce: 600000batched every ten minutes onmainand batches every five now.50becomes 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
mainrefused for them), or correct the claim and list the change.
Verified fine
- Rule-level reading. It matches
main'svalidatePolicy(mainevent-gate.ts:294-431) wherevermainloaded 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{};scopekeeps""; 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, andkeyBy.
- behavior names; a falsy value reads as
- Out of force. Rules now left out are ones
mainrefused, and with themmainrefused the file. __proto__. It stays an own, inert field (withoutEmpty,591-613).- Write ordering. Text written → config swapped → state cleared (
2376-2389). A failedwriteFileSyncchanges nothing in memory. The cleared timer becomes a no-op throughfireDebouncedeleting the entry. main'sdispose()change (#334). It composes with this:disposedrops timers wholesale and clearsinferring.onInferenceResumedre-adds an agent. Neither touchesclearStateFor.- connectome-host
main. Channel-mode'saddGatePolicy/removeGatePolicycatch throws, so the new refusals surface as a failed step. Its fourwakerecipes 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, anddebounceMs/rateLimit/passiveSamplenow 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.
mainbehaves identically, so this is noted separately rather than counted against this PR. listPolicyNamesupdatingerrorsfrom unapplied text. This is deliberate and tested (test/event-gate-strict-rules.test.ts:1042-1055), andgate_statusre-reads within a second.
What Sol couldn't reach: CI and GitHub state, and running anything (no installed dependencies). Claude covered both.
… 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>
… 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>
…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").
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>
|
Replying to this review.
At
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 |
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>
|
At
Written by Hamish-2569. — Lottie-2661 |
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>
|
Since
At
Written by Hamish-2569. — Lottie-2661 |
|
@Anarchid This merged into
Written by Hamish-2569. — Lottie-2661 |
…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>
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_ruleandaddGatePolicyrefuse 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_rulealike. We met them while rewording the gate's messages, and checked each with a probe atmain(92125b07) with membrane 0.5.87."always", a rule that wakes on every event it matches.validatePolicy's lastelsecovered anybehaviorthat 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_ruledeclaresbehaviora string, so the description's own example,behavior: { debounce: 60000 }, arrived as the text{"debounce": 60000}.{ match: { tagsAny: "chat:ambient" }, behavior: "skip" }was saved asmatch: {}, a rule that skips every event, DMs and mentions included. The same went forscopeas a string,channelas a number,matchas a string, and afilterwhosetypeisn'ttextorregex.pasthrough: truegave a governor that consumes the events it should let fall through.keybygave one rate-limit bucket for every channel, andpolicesin gate.json gave no rules at all.wake_add_ruletook its own parameters by name, so a misspelled one was dropped the same way.{ name: "quiet", tagsAny: ["chat:ambient"], behavior: "skip" }, withtagsAnybesidenamerather than inmatch, skipped every event, mentions included, andaddPolicysaved it asmatch: {}. A filter'spatternput inmatchwas dropped the same way.wake_add_rulegiventagsAnyas a parameter of its own saved its rule withmatch: {}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.debounce, thenrate_limit, thenpassive_sample, whatever their order. AkeyByorresetsof the wrong type was dropped. A gate.json whosepoliciesisn't a list, or which is itself a list, loaded as no rules under its default.FrameworkConfig.gate.config, which connectome-host fills from a recipe'swakeblock 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.Changes
match,filter,behavior,rate_limitandpassive_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 ofdebounce,rate_limitandpassive_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 asmatchTagswith nomatch, 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 besidename, anda field of gate.jsonfor gate.json'sdefaultin a rule. This usessrc/closest-names.ts, which fix: tool presentation and code_execution errors say what happened and what helps #413 added tomainto suggest a tool when a tool presentation names one that doesn't exist.wake_add_ruleandaddGatePolicyrefuse 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_rulechecks 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 ofdebounce,rate_limitandpassive_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.gate_status'serrorsand 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."always", and so a slip ofbehavioritself; two kinds as the first ofdebounce,rate_limitandpassive_sample; amatchthat 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"", afilterit couldn't read, a field put at a level where the gate doesn't read it, and akeyByorresetsof the wrong type, or a slip ofkeyBy,resetsorpassthrough, each dropped. In a rule the gate reads, amatchleft 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 misspelledpassthroughdoes, it holds the same back now, and the note says so. That goes for a rule that skips or defers too, whose note endsRead so, it skips every event it now matches, as earlier releases did.name; a behavior that's a list or an object holding no kind; arate_limitorpassive_samplethat isn't an object or lackstokens,refillIntervalMsorevery; adebounce,tokens,refillIntervalMsoreverythat isn't a finite number; a rate limit withtokensat or under 0, which woke on nothing; an invalid regular expression in afilter; and apassthroughthat isn't a boolean, or istrueon 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.scope, which the gate kept, only narrows a rule, since no event's type is""."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 whosetokensare 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 withtokensmissing or not a number, or a behavior that's a list or names no kind, was written to wake on something, so it counts.debounceunder 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. Apassive_sampleeveryat or under 0 is read as 1, and a fractional one as its next whole number, as its counter's check ran them. Arate_limitrefillIntervalMsunder 0 is read as a bucket that never refills, as it ran, so the rule wakes on its firsttokensevents and none after. One of 0 refills at once, so the rule limits nothing: run from the starting rules, it refilled the bucket once, sincefloor(elapsed / 0)isInfinity, and then never, since the bucket's clock becameNaN.applyRateLimitrefills at once for 0 and never under it, and so does the probe thatwake_add_rule's before-and-after table reads.wake_add_ruleandaddGatePolicyrefuse each, as before."always". A slip ofpolicies, 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 apoliciesthat 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.Wherepolicieswrites no rules, the file has none, so its default decides every event, as before: under"skip"or"defer", it holds back every one. Apoliciesholding nothing, such as"policies": {}, writes no rules, as[]does. A slip ofdefaultreads 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. Apoliciesleft 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.wake_add_rule,wake_remove_rule,addGatePolicyandremoveGatePolicyadd, 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, wheremaindelivered 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 answeredfalseand 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,keptUnreadableand thegate:config-kepttrace. 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, apoliciesthat 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 thatgate_statussays 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 ofpolicies, rules under another name or a rule's fields at the top, a change adds apolicieslist and keeps them as written, where an addition used to write the rules in force over the file, dropping them.noteinmatch, and one holding a field's whole name beside that field, such aschannelNotebesidechannel, or at gate.json's own top, where it can be a label of the file, such aspoliciesUpdated: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_ruleleaves it out of the rule it saves and names it in the result'signoredFields.addGatePolicytakes an optionalonIgnoredFieldscallback 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 (refillEveryMsforrefillIntervalMs).null, and a"", which a note names, saying what leaving it out does where that's more than no constraint: amatchleft out matches every event, and so does one whose every field is""; abehavioris"always"; akeyByleaves one bucket, or one count, for every event the rule matches; and gate.json'spoliciesleaves no rules, which under"skip"or"defer"holds back every event. The gate read most fields that way: it kept""insource,channel,mount,pathGloband a filter'spattern, wrote it fromwake_add_rulecalls 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 anullor"", in apassthrough, a behavior's kind or gate.json'sdefault, it now loads without it. Afilterwhosepatternis""narrows nothing, and is left out, with a note saying so. An absent field keeps its default (behavior"always",matchmatching anything), and an empty list still means no constraint."skip", aren't written, and the gate starts with no rules, as a host without them does, wheremainwrote 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 apoliciesthat isn't one and holds something, the start writes nothing over it and runs them under gate.json's default, unsaved, asmaindid 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, asmainran 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'sambientrule, narrowed to Zulip with an emptytagsAnyentry the gate drops, showed the recipe'sambient, 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.At start, …andIn the host's starting rules, …) and goes in an entry ofgate_status'serrorsthat 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;configSourcesays what's in force now, and is"initial"while the starting rules are, where it stayed"default".wake_add_rulereads its own parameters the same way.nullor""parameter is left out, so anullbehaviorbesidedebounceMs, or anullor""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 isnullis refused as one with none, where it was saved as"always". A""matchis 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 ofmatch,match.filter,rateLimitorpassiveSamplegiven 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 inignoredFields, and in any refusal of the call, within the refusal's own sentence where it's for a slip or a misplaced field.behaviorother 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.positionother than"append"or"prepend", or aninsertBeforeorinsertAfterthat 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.debounceMs: 60000, which works in both tool modes. The schema keepsbehaviora 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, orthe host's starting rules, orno 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.[gate] <path> couldn't be used (<reason>), so what it holds isn't in force...., 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.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 itstokensare 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_ruleandaddGatePolicyrefuse 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:rate_limitwithoutrefillIntervalMs, which woke on its firsttokensevents;debouncegiven as text, such as"60000", which debounced;passthroughthat isn't a boolean, which ran as its truthiness, so"false"ran as true;passthrough: trueon"always", which woke on every event it matched;name, which ran as written, so one on"always"woke on every event it matched.connectome-host's four recipes with a
wakeblock 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
debounceunder 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 arefillIntervalMsof 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.
addMessagethrows 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 itsaddGatePolicy, naming the fix, where an addition wrote over the file. Beside a slip ofpolicies, rules under another name or a rule's fields at the top, a change adds apolicieslist 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 apoliciesthat 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, ormatch: {}saved from a dropped field, can't be told from a rule written that way on purpose. Rules with an emptymatch, 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 anullor""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_ruleor 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.match,filter,behavior,rate_limit,passive_sample), each problem with how it's read. The simpler shape ismain'svalidatePolicy, which rewrote or dropped what it couldn't use without a word: the Problem section's cases.closestNamesandisNearMiss(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 thatpasthrough: trueleft 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 besideisNearMiss, where every name an object gives is seen (fieldNames). Without it,matchTagsin place ofmatch, ormatchesforwake_add_rule'smatch, 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 aspoliciesUpdated.tagsAnybesidenamewas said to do nothing while the rule matched every event.wake_add_ruleandaddGatePolicyrefuse any problem, in one refusal that names them all. Saving with a note would store a rule whose author is there to fix it.mainread it, and left out only wheremainrefused 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 eventsmainwoke on, wherever a rate limit, a sample or a debounce came after them."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 filemainloaded loads asmainread it, named: leaving it out of force, as for a slip ofpoliciesor 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 apoliciesholding nothing as no rules, as[]is read.maindid, 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.rulesForChange,writeConfig). Writing the rules in force, asmaindid, 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 ownwake_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.maindid, drops the operator's rules.describeStart,describeUnsaved) keep what the start did apart from gate.json's load error, whichmain'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, withconfigSource"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 rulesmainran 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,seedOrReconcilegivesstart-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), withoutOfForcewording 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, wheremain, 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.checkToolInputreadswake_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.ignoredFieldsandonIgnoredFieldsname a field the gate reads nowhere, inwake_add_rule's result and toaddGatePolicy'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, throughbeforeWriting, 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.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, besidewake_add_rule's example; left as it was, it would tell an agent something false about a batch it's waiting on.outOfForce'snextStart), 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 ofreadConfigFile'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.rulesRunsays what the gate runs, from the rules in force and where they came from, forgate_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.notesOf. Without it, a wrong type in a match reads as a nitpick, where saved it woke on every event.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
mainloaded 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 ofEventGatewith temporary directories, and one runswake_add_ruleas model-issued calls and checks the results the model is sent. Intest/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. Intest/event-gate-rate-limit.test.ts, a starting rule's fractionaleveryis 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. Intest/event-gate-unreadable-files.test.ts, a gate.json whosedefaultisn'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_ruleandwake_remove_rulegive 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 throughonIgnoredFields. A skipped privileged-users entry is quoted cut at 39 units, a unit shorter where that would split a surrogate pair.addPolicy: each behavior case, aNaNand 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,resetsandpassthroughof 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 besidename, a filter's inmatchand 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."skip"default, each with its note and the events it wakes on: a typo, the object form as text, a slip ofbehavior, amatchthat isn't an object, fields of the wrong type, list entries, an unreadable filter andresets, two kinds, akeyByslip and a wrong-typekeyBy, a misspelledpassthroughon a sampler, and an emptyscopeentry on a rule that skips. Rules that skip or defer with a dropped field, a slip or a badscopeentry, 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.nameon a rule that skips, a filter's inmatchand besidenameon a rule that skips, a rate limit's, a behavior's and a sample's besidename, gate.json'sdefaultin a rule and a rule'snameat 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."always", and under"skip"the ones that wake on nothing, a rule with no name that skips, one that defers with apassthrough, and a rate limit withtokensof 0 or of -2, with the note saying why."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.policiesthat isn't a list,"policies": {}, slips ofpoliciesanddefault, a slip holding a rule beside"policies": [], and a list of rules underrules, under"always", read with their notes; a baddefaultand 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 besidepoliciesunder another name, and a rule's fields at the top under"always"or beside apoliciesholding a rule, read as written and named; under"skip"or"defer", a slip ofpoliciesholding a rule or nothing, rules underpolicy,notes,disabledorhistory, a rule's fields, a match field, a rate limit's fields, akeyBybesidetokens, a filter'stypebeside itspattern,"passthrough": false,"tokens": 0,"rate_limit": {}or"behavior": []at the top, labels namednameorsourceor neither, a list of strings,"policy": [], a slip holding a rule beside"policies": {}, rules underrulesor 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 apolicieslist beside it, in which the rule it adds wakes the agent; and apoliciesthat 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.policiesisn'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 ofpoliciesholding a rule, with nopoliciesor beside{}or[], rules underpolicyorrules, a rule's fields at the top, with nopoliciesor beside[], and a"source"label there, a change adds apolicieslist 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; anonIgnoredFieldsthat throws logged once the change is traced, the change answering as made; andwake_add_rulekeeps a rule with a problem it didn't change.channelNotebesidechannel, a rule'smatchTagswith nomatchread asmatchleft out, anddefaultBehaviorandpoliciesUpdatedat the top, so a file under"skip"withpoliciesUpdatedand no rules is used, quiet, and a"__proto__"in a match, which narrows nothing; a field left out byaddPolicy, told toonIgnoredFields, or logged without it;nulland""fields throughaddPolicyand 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,keyByorpolicies, under"always"and"skip", leaves, and amatchleft 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 emptymatchsays nothing; a filter of a type the gate can't read refused beside its""pattern; the note stays with the text in force.addPolicy, load again with no errors and are written back unchanged. This one is a control and passes on the parent commit too.policiesisn'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 whosepoliciesisn't one and holds something, lacks beside it, under its default and unsaved, withconfigSource"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, apoliciesleft 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, withconfigSource"initial".addPolicyrefuses each; a rule with a number and another problem is read past both.wake_add_rule: the object form as text, a typo, a number and a list forbehavior; aposition, aninsertBeforeand aninsertAfterof the wrong kind; an anchor no rule has; a slip of a parameter,matcheswith nomatchamong them; a field ofmatch, ofmatch.filterand ofrateLimitgiven 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 insidematchthat 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. Anullbehavior besidedebounceMs, anullposition and a""anchor left out, a""matchnamed as matching every event, an unknown parameter named, andmatchNotebesidematchnamed and saved without.ignoredFieldsfor 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.mainnow 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 throughwake_add_rulewith the file there, which keeps that rule and keeps no copy.Red first: on the
srcthis change builds on, #371's head (29afb436), with this change's four test files, 57 of their 161 tests fail: 40 of the 43 inevent-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 ofevent-gate-rate-limit.test.ts; the 2 debounce-bounds tests ofevent-gate.test.ts; and 12 of the 29 inevent-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 passaddPolicytheonIgnoredFieldsoption 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, at80503bef, with the one of the 186 whose code it moved; 7 of the two commits after it, atf97f8fb6; 25 of the commit answering the maintainer's review, atdce014e9, 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, at85e616de, 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, at065a6f8c, 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, atd6af7ad5, 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, at5fe50b2f, 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, atf01b68eb; 20 of the next, at5bc9e522, 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, atb7f50260, 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, at5009d968, with the 43 earlier ones whose only catching test it changed; and 4 of the one after it, at79338031, each place gate.json's or the privileged-users file's text is parsed put back toJSON.parsein place ofmain'sparseGateJson. The merge after it brings in #371's head again and adds none. And 12 of the next, atbb90461d, 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 wherewake_add_ruleshows a misplaced field; 11 of the one after it, at1ee671bf, one of them an earlier piece pointed at the code it rewrote; 14 of the next, at74eb735b, 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, atc45f0437, 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 asmainread them, each named, a slip ofpolicieswith 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 emptiedmatchand akeyByleft 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 ofwake_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 bywake_add_rulefrom its own schema, apoliciesholding 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, anonIgnoredFieldscalled contained after the trace, the starting rules a gate.json with no rule list lacks run beside it, withconfigSource"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 ranmain's gate, ate6703634, 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 filemainloaded, this change loads, and it finds no eventmainwoke on that this change doesn't, wheremainloaded the file, and 139 wheremainrefused 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 eventmainwoke on goes quiet. Two follow theambientcase 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 besidename; a filter's field inmatch; rules underpolicy, underrulesbeside"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 namedname,type,keyByorsource, a"comment", andnoteslists; a filter'stypebeside itspatternat the top; fields holding nothing there, an emptytagsAnyormatchand"rate_limit": {}, and"tagsAny": [""]and"behavior": []; and"policies": {}and a slip"polices"holding[]or{}. Both decide alike on every event of every file.mainsaved a match field besidename, throughaddPolicyandwake_add_rule, as a rule with no match, and a filter's field inmatchwithout 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 ofpolicies, whichmainwrote 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
writeFileSync, not by a full disk.Out of scope
addPolicyandremovePolicyresolve differently.Stacking
This builds on #371, and its diff includes #371's six commits and its merges of
mainuntil 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 filesmainloaded, that leaving out a rule that skips or defers silenced eventsmainwoke 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, andwake_add_rulenamed 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.80503befanswers 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 besidemain'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 at80503bef, and only their notes change. Anarchid's review off97f8fb6then found that a filemainloaded 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.dce014e9answers the four, after a merge of #371's head andmain, 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 mergesmain, and the commit after it parses gate.json's kept text throughmain'sparseGateJsontoo. The merge after that brings in #371's head again, which mergesmainat091fa493, 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 through79338031, with probes of his own againstmain, 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 at79338031wakes the agent on every eventmainwoke 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