candor-java v0.27.0
[0.27.0] — 2026-08-07
-
…and the guard now enumerates a dep directory exactly as the LOADER does. The first repair
registered the directory's files with a FLAT read beside a RECURSIVE loader walk, so a report one
level down stayed unguarded — and for--deps, which writes one subdirectory pername@version, the
nested layout is the ORDINARY one. A guard that enumerates differently from the loader guards a
different set of files. One enumeration now serves both. -
⚠ A
--gate-jsonsink INSIDE adepsDIRECTORY destroyed the operator's dep report.deps
accepts a directory —--workspacewrites.candor/deps/and hands that back, so it is the common
spelling — and the loader walks it and reads each report inside. The §3.3.1 sink-over-input guard
registered only the DIRECTORY, which never equals a file within it, so--gate-json <depdir>/lib.json
was unguarded: arming destroyed the report, the run chained the wreckage and exited 0 withok: true
written over the input. All four engines. The FILE spelling of this channel had been guarded for a
release; the directory spelling had not, and no row posed it. Now pinned by conformance PART 36 (b14),
which asserts both the refusal AND that nothing was written. -
The
gateverb arms its--gate-json -stream sink, and one sink is written once. Arming was
wired into the SCAN pre-pass only, so on the supply-chain surface an exit-2 during argument parsing
left stdout EMPTY while the same verb refusing later streamed the document — one operator mistake,
two answers, decided by how early it was caught. Separately,--json --gate-json -names ONE artifact
twice and both were written, putting two concatenated JSON objects on a stream SPEC §3.1 requires to
carry the fail-closed document "exactly once, as the streams only content". Both fixed; conformance
PART 36 gained rows (b4)-(b6), which is why they were found at all. -
⟨0.27⟩ The three verdict-document cells (SPEC §3.1/§4, conformance PART 36). (1) The composed
document no longer carriesrefused/reasonbesideviolations— the refusal document's
discriminator must not ride a verdict; the disclosure isunevaluated(this engine already listed
every rule; an unreadable policy now gets one whole-file entry instead of an empty list). (2) The
stream sink:--gate-json -gains a shutdown-hook analog of arming, so ANY exit-2 cause (an unknown
flag, a broken config, a crash) leaves the fail-closed refusal as stdout's only content instead of an
empty stream. (3)zeroMatch: the §4 zero-match list now rides the verdict document on BOTH routes,
code-point sorted (Query.BY_CODE_POINT) and deduplicated; it was stderr-only. -
⚠
"(?U)\\s+"written as"(?U)\\s+"— one backslash — made the sink guard blind to a TAB. In a
Java string literal\\sis JEP-368's SPACE escape, so the guard's regex was(?U) +. A
policy\\tgate.policyconfig was read fine by the real loader and not recognised by the guard, so
--gate-json <that policy>destroyed it and the run exited 0 with"ok": true"while the gate was
live. Thedepssplit had the same escape and covered no dep after the first. -
The guard now calls the engine's own config loader instead of re-deriving it. That is the fix for
the class rather than for the three instances: the hand-written pre-pass also computed the config's
home directory as parent-of-parent unconditionally (the loader only steps out of a trailing.candor/
segment, so an out-of-treeCANDOR_CONFIGanchored one level too high and the guard protected a path
the run never reads), and splitdepson the path separator alone. A second parser is a second set of
holes. -
⚠ The
gateverb never read the config-declaredpolicyat all — rust, ts and swift all do, so a
checked-inpolicykey gated in three engines and refused with "a policy is required" here: a 3-vs-1
split on the family's own "one config, one meaning" claim, on the supply-chain surface. Caught by a
new conformance row whose control — assert the config policy GATES before asserting anything about
the sink — is what found it. -
trimUnicodenamed U+00A0 and missed the rest of\\p{Zs}.policy gate.policy\\u202Frefused at
exit 2 here while four engines trimmed it and ran; and a LEADING no-break space left an empty first
token, so the key became''and the whole line vanished as "unknown config key" — apolicyline
silently dropped and a MISMATCHEDenginepin silently passed. Now the whole space-separator category. -
The scan target may appear anywhere in argv, matching the other three engines.
-
⚠ The
gateverb got none of the ⟨0.27⟩ guard, so the reference engine still reproduced §3.3.1's own
worked example.gate --report R --policy P --gate-json Pexited 0 with"ok": trueafter
overwritingP(control, separate sink: exit 1) — the supply-chain surface, the one a consumer points
at a report someone else produced. The sweep touchedCandor.javaand skippedQuery.java. It also
still armed mid-flag-loop, so an unknown flag BEFORE--gate-jsonleft the previous run's green while
the same mistake spelled the other way wrote a refusal. -
⚠ The collision guard keyed on the FLAG, so a policy from
.candor/configwas invisible to it —
the checked-in form, i.e. the one CI actually uses.--gate-json <that policy>destroyed it and exited
0 with"ok": true, in all four engines.Config's own ⟨0.24⟩ note forty lines away says exactly this:
"a policy does not change what it is according to how the operator handed it over." The guard now
enumerates every channel — flag, env, the config'spolicy/baseline/deps,CANDOR_DEPS, and the
config file itself — reading the config leniently so it cannot pre-empt the real load's refusal. -
⚠
--gate-jsonbefore the target left a stale green.rejectUnknownFlag(args[0], …)ran before the
pre-pass and java's grammar forced the target intoargs[0], socandor --gate-json G <target>exited
2 as an "unknown flag" with yesterday's verdict still atG. The target may now appear anywhere in
argv, matching rust, ts and swift — one contract, four grammars is what this family exists not to be. -
The
(?U)whitespace fix stopped one line short and became a FALSE REFUSAL.engine<NBSP>java<NBSP>v0.27.0
— a correct qualified pin — still reachedaddEnginePinas one token and failed MALFORMED, so this
engine exited 2 where the other four exited 0; the same for a trailing NBSP on any value, since
String.strip()usesCharacter.isWhitespace, which excludes U+00A0 by definition. Fixing a fail-open
by breaking a working config is the mirror defect, not a stricter reading.depsvalues stay
ASCII-split: those are PATHS, and a path may legitimately contain a non-breaking space.