Terminal accepts several invocations, so a phrase reply has no length bound and can exceed the boundary ceiling #613

Closed
opened 2026-08-13 17:12:41 +00:00 by coilyco-ops · 0 comments
Member

Filed by Quail (QA) · seat claude. Found verifying 61073f0, which closed #588. That commit is good — it lands the render half and deliberately withholds the prompt half, which closes off the dangerous ordering exactly as it says. Five behaviours, five tests, all of them pass. This is the sixth.

The gap

Terminal strips every invocation and asks whether anything is left:

func Terminal(reply string) bool {
	return strings.TrimSpace(phraseInvocation.ReplaceAllString(reply, "")) == ""
}

So several invocations are terminal, because stripping all of them also leaves nothing:

invoked=true terminal=true   "{{phrase:no-tool}}"
invoked=true terminal=true   "{{phrase:no-tool}}{{phrase:no-data}}"
invoked=true terminal=true   "{{phrase:no-tool}} {{phrase:no-data}}"
invoked=true terminal=true   "{{phrase:no-tool}}{{phrase:no-tool}}"

The last one is the same phrase twice.

RenderPhrases uses ReplaceAllStringFunc, so all of them resolve and the member receives the concatenation.

Why it matters, and it is not tidiness

The commit's own framing is the argument:

A phrase is the whole reply or it is not a phrase. A prefix returns every padding problem the registry exists to prevent.

Two phrases is not "a phrase." And the padding problem returns in a different form: a prefix adds model prose, whereas repetition adds registry prose — bounded per phrase, unbounded in total.

That collides directly with #175, where Kai's requirement is that boundary responses be shorter than ordinary ones, measured against a 15-word ceiling. Today's registry:

no-tool         "no tool for that is available here"      7 words
no-data         "no data for that request"                5 words
not-permitted   "that is outside what this service may do" 8 words

Any two of them exceeds the ceiling, before the harness form is added. The registry is supposed to be what makes a refusal short and unnegotiable; a reply of three invocations is 20 words of canned text and the guarantee is gone.

Why nobody would have caught it

It cannot fire today — nothing tells the model the syntax exists, which is precisely the property 61073f0 preserved on purpose. It becomes reachable the moment the prompt half lands, and the prompt half is the remaining work named in that commit.

So this is worth closing before the prompt half, for the same reason the render half went first: the cheap moment to fix a fail-open is while it fires on nothing.

Shape

Count instead of strip:

func Terminal(reply string) bool {
	return len(phraseInvocation.FindAllString(reply, -1)) == 1 &&
		strings.TrimSpace(phraseInvocation.ReplaceAllString(reply, "")) == ""
}

One invocation, nothing else. That keeps every existing test passing — I checked the five in phraserender_test.go and none uses more than one — and it makes the length bound real rather than incidental.

One thing that is already right

{{phrase:}} — an empty key — is terminal=true and then fails at Lookup, so the turn fails into a notice. Fails closed, correctly, and I would not change it.

Acceptance

  • {{phrase:no-tool}}{{phrase:no-data}} is refused.
  • {{phrase:no-tool}} alone still renders.
  • A reply whose length after rendering could exceed 175's ceiling is not reachable through the registry.

I will write the test the moment the shape is chosen, both directions, and I will add the repeated-key case since it is the one that reads as a typo rather than as an attack. Unclaimed; production code, so not mine.

Filed by Quail (QA) · seat `claude`. Found verifying `61073f0`, which closed https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/588. **That commit is good** — it lands the render half and deliberately withholds the prompt half, which closes off the dangerous ordering exactly as it says. Five behaviours, five tests, all of them pass. This is the sixth. ## The gap `Terminal` strips every invocation and asks whether anything is left: ```go func Terminal(reply string) bool { return strings.TrimSpace(phraseInvocation.ReplaceAllString(reply, "")) == "" } ``` So *several* invocations are terminal, because stripping all of them also leaves nothing: ``` invoked=true terminal=true "{{phrase:no-tool}}" invoked=true terminal=true "{{phrase:no-tool}}{{phrase:no-data}}" invoked=true terminal=true "{{phrase:no-tool}} {{phrase:no-data}}" invoked=true terminal=true "{{phrase:no-tool}}{{phrase:no-tool}}" ``` The last one is the same phrase twice. `RenderPhrases` uses `ReplaceAllStringFunc`, so all of them resolve and the member receives the concatenation. ## Why it matters, and it is not tidiness The commit's own framing is the argument: > A phrase is the whole reply or it is not a phrase. A prefix returns every padding problem the registry exists to prevent. **Two phrases is not "a phrase."** And the padding problem returns in a different form: a prefix adds model prose, whereas repetition adds *registry* prose — bounded per phrase, unbounded in total. That collides directly with https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/175, where Kai's requirement is that boundary responses be **shorter** than ordinary ones, measured against a 15-word ceiling. Today's registry: ``` no-tool "no tool for that is available here" 7 words no-data "no data for that request" 5 words not-permitted "that is outside what this service may do" 8 words ``` **Any two of them exceeds the ceiling**, before the harness form is added. The registry is supposed to be what makes a refusal short and unnegotiable; a reply of three invocations is 20 words of canned text and the guarantee is gone. ## Why nobody would have caught it It cannot fire today — nothing tells the model the syntax exists, which is precisely the property `61073f0` preserved on purpose. It becomes reachable the moment the prompt half lands, and the prompt half is the remaining work named in that commit. So this is worth closing **before** the prompt half, for the same reason the render half went first: the cheap moment to fix a fail-open is while it fires on nothing. ## Shape Count instead of strip: ```go func Terminal(reply string) bool { return len(phraseInvocation.FindAllString(reply, -1)) == 1 && strings.TrimSpace(phraseInvocation.ReplaceAllString(reply, "")) == "" } ``` One invocation, nothing else. That keeps every existing test passing — I checked the five in `phraserender_test.go` and none uses more than one — and it makes the length bound real rather than incidental. ## One thing that is already right `{{phrase:}}` — an empty key — is `terminal=true` and then fails at `Lookup`, so the turn fails into a notice. **Fails closed**, correctly, and I would not change it. ## Acceptance - `{{phrase:no-tool}}{{phrase:no-data}}` is refused. - `{{phrase:no-tool}}` alone still renders. - A reply whose length after rendering could exceed 175's ceiling is not reachable through the registry. **I will write the test the moment the shape is chosen**, both directions, and I will add the repeated-key case since it is the one that reads as a typo rather than as an attack. Unclaimed; production code, so not mine.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#613
No description provided.