Give a Jira workflow rule a memory that survives, without blowing the prompt budget
Gabriela Perdum
Author
14 min readSeptember 7, 2026
Key takeaways
END STATE: a workflow rule that accumulates lessons about YOUR instance across runs, injected under a byte cap you can prove holds, with duplicates folded in rather than appended.
Cap the prompt block in UTF-8 BYTES, never in string length. Measured: a Japanese memory block was 95 bytes for 53 characters — a char-based cap overshoots the budget by ~1.8x, and up to 3-4x for pure CJK.
Jaccard at 0.85 is a HIGH bar. Two sentences a human reads as identical scored 0.733 and did NOT merge. Dedup catches re-saves, not paraphrases — do not size your storage assuming it catches both.
Scope WIDENS on merge, never narrows: the same fact saved under two projects is promoted to global so it injects everywhere.
Defang prompt-fence tokens on the way IN to the prompt. The content is text your AI proposed, and it is untrusted.
A workflow rule that calls an AI starts every run knowing nothing. It does not know that your Due Date field rejects DD/MM/YYYY, that Story Points is missing from the Bug screen in one project, or that the Sprint field cannot be written through the REST API on your instance. It finds out, sometimes expensively, and then it forgets. That is the cost an AI workflow validator carries by default, and the memory store exists to pay it once.
The fix is a memory: a small store of short lessons about this Jira, injected into the prompt on the next run. The hard part is not storing them. The hard part is that a memory which grows without a ceiling eventually crowds out the thing the prompt was for, and the obvious way to build that ceiling is wrong in a way that only shows up in another language.
Note
Prerequisites
Node 18 or later. Everything below was run on v24.15.0.
src/memories.js from CogniRunner — 270 lines, and the only file this tutorial touches.
No Jira, no Forge deploy, no credentials. We stub the one storage import and run the real module locally, which is the point: you want the cap proven before it decides what your AI sees.
Stand up a local harness so the real module runs without Forge.
2
Save a lesson and watch two near-identical ones both get stored.
3
Measure the dedup threshold instead of trusting the constant's name.
4
Build the injection block and read the ordering the ranking produces.
5
Prove the byte cap in ASCII, where nothing interesting happens.
6
Break the cap with Japanese, which is where a character count fails.
7
Defang the fence so stored text cannot escape into your prompt.
8
Check the save-time caps that stop the store growing without bound.
9
Enable injection only on the paths that should pay for it.
Step 1 — Stand up a local harness
memories.js has exactly one external dependency: the Forge key-value store. Swap it for an in-memory stub and every other line runs unchanged.
js
1// kvs-stub.mjs2const db =newMap();3exportdefault{4get:async(k)=> db.get(k),5set:async(k, v)=>{ db.set(k, v);},6delete:async(k)=>{ db.delete(k);},7};
Then take the module itself, rewriting only the import:
bash
1sed's|import storage from "@forge/kvs";|import storage from "./kvs-stub.mjs";|'\2 src/memories.js > memories.mjs
How you know it worked: diff the harness copy back against the original with the import restored. It should print IDENTICAL and nothing else — if anything else differs, you are testing your edit and not the shipped code.
That one-line swap is the whole reason the rest of this is trustworthy. Everything below is the real ranking, the real dedup and the real cap.
Step 2 — Save a lesson and watch it dedup
A memory is a short string plus scope and confidence. Save two lessons that say the same thing in different words, and one scoped to a single project:
js
1awaitsaveMemoryCandidate({2content:"The Due Date field on this instance rejects DD/MM/YYYY; it needs YYYY-MM-DD.",3source:"user",4});5const second =awaitsaveMemoryCandidate({6content:"Due Date field on this instance rejects DD/MM/YYYY and needs YYYY-MM-DD instead.",7source:"test",8});
Here is what actually happens:
bash
1node probe1.mjs
text
1second save merged? false
2third save merged? false
3memories stored: 3
4 reinforcements=0 scope=ENG Story Points is not on the Bug screen in project ENG
5 reinforcements=0 scope=GLOBAL Due Date field on this instance rejects DD/MM/YYYY a
6 reinforcements=0 scope=GLOBAL The Due Date field on this instance rejects DD/MM/YY
Two memories that a person would call identical were both stored. That is not a bug, and it is the most useful thing in this tutorial, so it gets its own step.
Step 3 — Measure the dedup threshold instead of trusting it
Dedup fires when either the normalised text matches exactly, or the Jaccard similarity of the token sets reaches JACCARD_DEDUP_THRESHOLD, which is 0.85. Jaccard is the size of the intersection over the size of the union — so it counts shared words, and it has no idea that two sentences mean the same thing.
Measure it rather than assuming:
bash
1node jaccard-probe.mjs
text
1jaccard=0.733 merges@0.85=false
2 A: The Due Date field on this instance rejects DD/MM/YYYY; it
3 B: Due Date field on this instance rejects DD/MM/YYYY and nee
45jaccard=1.000 merges@0.85=true
6 A: Story Points is not on the Bug screen in project ENG.
7 B: Story Points is not on the Bug screen in project ENG
0.733 for a pair most readers would call the same sentence. The words the, field, it, needs, and, instead differ between them, and at this length a handful of differing tokens is most of the union.
How you know it worked: the second pair, differing only by a trailing full stop, should print jaccard=1.000 merges@0.85=true. Confirm your paraphrase pair prints a value BELOW 0.85 — if it merges, your threshold is lower than 0.85 and you are folding together lessons that are not the same lesson.
What this means practically: the dedup protects you against the same text being saved twice, not against the same idea being phrased twice. An auto-capture path that generates its own wording will accumulate near-duplicates, and you should size MAX_MEMORIES expecting that rather than assuming the threshold will clean up after you.
Before normalisation runs, the text is masked: issue keys become ISSUE and long digit runs become N. That is what makes a failure signature stable across projects:
bash
1node signature-probe.mjs
text
1normalised A: issue failed: field customfield_N is not on screen
2normalised B: issue failed: field customfield_N is not on screen
3sig A=af871f85 sig B=af871f85 equal=true
ENG-412 and OPS-9987 hitting the same wall produce one signature, which is what stops a recurring instance-wide problem from being recorded ninety times.
Step 4 — Build the injection block and read the ordering
buildMemoryBlock selects what is eligible — not disabled, and either unscoped or scoped to the project you are running in — then sorts and packs whole lines until the budget is spent.
The sort is: project-scoped before global, then confidence descending, then reinforcements, then most recently updated. Run it for project ENG:
bash
1node probe5.mjs
text
1--- block built for project ENG ---
2- [user] ENG-only rule about screens.
3- [test] Sprint field is not editable through the REST API on this instance.
4- [user] Global HIGH confidence note.
5- [user] Global low confidence note.
How you know it worked: you should see the ENG-scoped line first even though its confidence (0.3) is the lowest of the four. Scope outranks confidence, deliberately — a fact about the project you are actually in beats a stronger general fact. If your highest-confidence global sorts to the top, your comparator is running the wrong key first.
That block also shows the merge behaviour worth knowing about. The Sprint memory was first saved scoped to ENG, then saved again from OPS:
text
1after 1st save (ENG) scope=ENG reinforcements=0
2after 2nd save (OPS) scope=GLOBAL reinforcements=1 merged=true
3scope widened, never narrowed: true
The same fact learned in two projects is no longer a fact about one project, so it is promoted to global and injects everywhere. Scope only ever widens on merge. The alternative — letting a global memory be narrowed into a project when it happens to be re-saved there — silently loses it everywhere else, and that failure is invisible because nothing errors.
Step 5 — Prove the byte cap in ASCII
capBytes defaults to 8192. Pack six ASCII memories and squeeze:
bash
1node probe2.mjs
text
1capBytes= 8192 lines=6 utf8Bytes=599 utf16Chars=599 under cap=true
2capBytes= 400 lines=4 utf8Bytes=399 utf16Chars=399 under cap=true
3capBytes= 200 lines=2 utf8Bytes=199 utf16Chars=199 under cap=true
4capBytes= 100 lines=1 utf8Bytes=99 utf16Chars=99 under cap=true
How you know it worked: every row should print under cap=true, and the block never ends mid-line — the loop tests the candidate block including the newline before accepting it, so a line that would overflow is dropped whole rather than truncated into nonsense. A half-sentence in a prompt is worse than a missing one, because the model will try to interpret it.
Notice that utf8Bytes and utf16Chars are identical in every row. That is exactly why this bug ships: in English, the wrong implementation and the right one are indistinguishable.
Step 6 — Break the cap with Japanese
Now store the same kind of lessons in Japanese and pack them under a 200-byte cap:
bash
1node probe3.mjs
text
1capBytes = 200
2lines packed = 1
3UTF-8 bytes = 95 <- what the cap actually measures
4UTF-16 chars = 53 <- what a naive .length would have counted
5bytes per char = 1.79
6within cap = true
78--- the block ---
9- [user] 期日フィールドは DD/MM/YYYY を拒否します。YYYY-MM-DD が必要です。
Fifty-three characters, ninety-five bytes. Had the cap counted characters, it would have kept packing until it reached 200 characters — roughly 358 bytes, close to double the budget it was asked to respect. On a line of pure Japanese with no embedded ASCII the ratio is 3 bytes per character, and the overshoot is threefold.
This is why the implementation measures with a TextEncoder:
How you know it worked: confirm the two numbers differ — you should see UTF-8 bytes = 95 against UTF-16 chars = 53. If they are equal, your test data is ASCII and the test is not exercising the thing it claims to.
The fallback is worth a moment. If TextEncoder is unavailable it returns length * 4 — the worst case for UTF-8, so the estimate is always an over-count, and an over-count under-fills the prompt. A fallback that guessed low would silently reintroduce the exact overshoot the function exists to prevent.
The same helper guards the hard storage limit, MAX_SERIALIZED_BYTES = 230000, against a key-value cap of 245,760 bytes. Same reasoning, higher stakes: overshooting the prompt budget wastes tokens, overshooting the storage cap throws.
Step 7 — Defang the fence before injecting
Memory content is text a language model proposed and a user accepted. It goes into a prompt inside a fence. So it is untrusted input to the next prompt, and it must not be able to close that fence:
Any run of three or more angle brackets collapses to two, so no stored string can open or close a literal fence. The sentence survives; its ability to escape does not.
Note where this is applied — inside buildMemoryBlock, on the way into the prompt, not on the way into storage. That is the right end. Sanitising at write time protects only the records written after you deployed the sanitiser; sanitising at read time protects every record, including the ones already sitting in the store.
Step 8 — Check the save-time caps
Three limits stop the store growing without bound, and they apply at save:
text
1MEMORY_CONTENT_MAX 400 characters per memory
2MAX_MEMORIES 200 entries kept
3MAX_SERIALIZED_BYTES 230000 bytes for the whole array
How you know it worked: save a 600-character memory and read it back; it should print a stored length of exactly 400.
bash
1node caps-probe.mjs
text
1submitted=600 chars stored=400 chars
Content is truncated at save, not at read, which means a long lesson is permanently shortened the moment it is stored. If your capture path writes 600-character explanations, the last 200 characters are gone and no later change to the cap brings them back. Write the lesson short, or lose its ending.
Note the asymmetry that is easy to miss: MEMORY_CONTENT_MAX counts characters, while the block cap and the storage guard count bytes. That is defensible — a per-item content limit is about readability, and the byte guards are about real limits — but the two limits bite at very different points. Measured: 400 CJK characters is exactly 1,200 bytes, so as an injected line it costs 1,209 — and six of them fill the default 8,192-byte budget. Six, against a 200-entry ceiling you will never reach. The byte budget is the real constraint on non-ASCII content, by a factor of about thirty.
Step 9 — Enable injection only where it pays
Three switches decide whether any of this runs, and their defaults encode a cost judgement worth copying:
autoCapture is off. Nothing writes a memory unless a person accepts it. A store that fills itself is a store nobody audits, and — given step 3 — one that accumulates near-duplicates it will never fold together, because auto-generated wording varies exactly enough to sit under 0.85.
injection is on. Code generation and fix prompts get the memory block by default, because that is where a wrong guess about your instance costs a whole round trip.
runtimeInjection is off, and this is the one to think about. It controls injection into validators, real workflow conditions and semantic post-functions — code that runs on every workflow transition. Turning it on adds the memory block to every AI call your workflow makes, which means the byte budget from steps 5 and 6 stops being a theoretical ceiling and starts being a per-transition tax.
Do the arithmetic before enabling it. An 8,192-byte block is roughly 2,000 tokens of English, and on a busy project with a few hundred transitions a day that is a few hundred thousand tokens spent restating the same facts. That may well be worth it — a validator that stops rejecting valid dates pays for itself — but it should be a decision, not a default.
How you know it worked: read the settings back after a patch and confirm the value you did not set is unchanged. saveMemorySettingsInternal merges rather than replaces, so patching one flag should print the other two at their previous values, not at their defaults.
The read is deliberately strict about which way each default falls. autoCapture: stored.autoCapture === true treats anything that is not exactly true as off, while injection: stored.injection !== false treats anything that is not exactly false as on. A malformed or partially-written settings record therefore fails safe in both directions — capture stays off, injection stays on — rather than inheriting whatever a truthy check happened to make of it. Store deliberate garbage and confirm it:
The string "yes" did not enable capture and the string "no" did not disable injection. Both fell to the safe side, which is the behaviour you want from a record that something else may have half-written.
Key takeaways
A memory stops the rule paying for the same lesson twice. Eligible memories are ranked project-scoped ahead of global, then by confidence, then reinforcements, and packed whole-line until the budget is spent.
Cap prompt content in UTF-8 bytes, never in string length. In English the two are the same number, so the wrong version passes every test you are likely to write. In Japanese it overshoots by 1.8x on mixed content and threefold on pure CJK. Measure with a TextEncoder, and make any fallback over-count rather than under-count.
Jaccard at 0.85 catches re-saves, not restatements. A paraphrase measured 0.733 and was stored as a second memory. Size your store expecting near-duplicates rather than assuming the threshold will clean up after you.
Scope widens on merge and never narrows. The same fact learned in two projects becomes global instead of being trapped in whichever project saved it last.
Defang the fence at injection time, not at write time. The memories are text an AI proposed, they are untrusted, and the ones already in your store were written before you thought about it.