Verified on station-v1.5.8 (built_at: 2026-08-25T13:45:28Z, core_commit: 4b8bb7c7), the current release. My own station runs v1.5.6, so I pulled the v1.5.8 tarball and re-ran everything below against it. Line numbers are v1.5.8. I verified by importing v1.5.8's approval_airlock.validate directly; I did not stand up a v1.5.8 station, so the end-to-end path is read from routes/commands.py:172 and :288 being unchanged.
What happens
approval_airlock.validate is the Semantic Firewall every command passes through before an operator sees the approval card. It accepts exactly four type names — array, string, number, object. The Publisher FAQ documents six, plus an alias:
type—string,number,integer,boolean,object,array.textis normalized tostring.
So integer and boolean are documented, are passed through to MCP hosts as advertised capability, and are rejected at execute time with wrong type for '<field>' (want integer). (text also fails, but the FAQ presents it as an alias the normalizer rewrites, and it does — that one is arguably working as designed.) This hits the flat name-keyed shape — the one the FAQ calls what "publishers today write" and that nearly every shipped module uses. Three installed modules that are not mine are affected right now.
Separately, the same function is the reason a JSON Schema manifest cannot execute at all, and — worse — inverts: a correct payload is rejected as unknown field, while an empty payload passes, including on write_requires_approval / risk: high commands.
Why
One root cause: approval_airlock.validate never learned the manifest schema vocabulary that mcp_server._normalize_module_input_schema (v1.5.8, mcp_server.py:968) and the FAQ both define. routes/modules.py:1374 hands it the manifest verbatim:
cmdreg.COMMANDS.append(dict(cmd, wired=True))
Symptom A — type vocabulary. approval_airlock.py:213-217:
t, v = spec.get("type"), inputs[field]
ok = (t == "array" and isinstance(v, list)) or (t == "string" and isinstance(v, str)) or \
(t == "number" and isinstance(v, (int, float)) and not isinstance(v, bool)) or \
(t == "object" and isinstance(v, dict)) or t is None
if not ok:
errors.append("wrong type for '%s' (want %s)" % (field, t))
integer, boolean, text match no disjunct and t is None is false, so ok is False. The contradiction with MCP is exact: _normalize_module_input_schema maps only text/enum to string (prop["type"] = {"text": "string", "enum": "string"}.get(t, t)), so integer and boolean are advertised verbatim in tools/list. An MCP host is told integer is valid, sends 5, and the airlock rejects it.
Symptom B — JSON Schema shape. approval_airlock.py:199-221 iterates the schema as if every top-level key were a field name. For a JSON Schema manifest the keys are type, additionalProperties, properties, required:
for field, spec in schema.items():
if not isinstance(spec, dict):
continue
if spec.get("required") and (field not in inputs or ...):
errors.append("missing required field: " + field)
...
for field in inputs:
if field not in schema:
errors.append("unknown field: " + field)
"type" → "object" (str) and "required" → ["query"] (list) are both skipped by the isinstance guard — which is why required-ness silently stops being enforced. "properties" survives the guard but contributes nothing. Then every real field name falls out of the last loop as unknown field.
The FAQ's answer to What's the correct shape for input_schema in module.json? ends:
Station v0.30+ normalizes this into valid MCP JSON Schema automatically on everytools/list. Real JSON Schema (hastypeorpropertiesat the top level) is also accepted and passed through. Either shape works.
That is true of tools/list. It is not true of execution: the normalizer is used only at mcp_server.py:1093.
Reproduce
From a stock install, no exotic config.
Symptom A:
railcall market install sami666/singleops-browser(0.2.7 — its manifest declareslimitas{"type": "integer", "required": false}).- Run
singleops.browser_list_unscheduledfrom Studio → Commands, or the CLI, passinglimit: 5. - Receipt:
failed_with_receipt, notevalidation failed: wrong type for 'limit' (want integer).validateonly checks a field that is present, so omittinglimitpasses validation — I verified the validator's behaviour, not this command's, which needs credentials I do not hold.
Symptom B:
- Publish or install a module whose command declares
input_schemaas{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}. - Run it with a correct payload →
validation failed: unknown field: query. - Run it with no inputs → validation passes and the approval card is presented with an empty payload.
Read-only, deterministic, against the shipped release:
python3 -c "
import sys,json; sys.path.insert(0,'v158/workbench')
import approval_airlock as a
for t,v in [('string','x'),('number',5),('integer',5),('boolean',True),('object',{}),('array',[]),('text','x')]:
print(t, a.validate({'id':'x','input_schema':{'f':{'type':t}}}, {'f':v}))"
string (True, [])
number (True, [])
integer (False, ["wrong type for 'f' (want integer)"])
boolean (False, ["wrong type for 'f' (want boolean)"])
object (True, [])
array (True, [])
text (False, ["wrong type for 'f' (want text)"])
Evidence
Observed on my station today, and re-run against v1.5.8.
Symptom A, third-party modules installed here, all declaring the flat shape:
sami666/singleops-browser0.2.7 —singleops.browser_list_unscheduled,limit:{"type": "integer", "required": false, "description": "Rows to return. A SAFETY CAP when 'since' is set, not a selector."}. That safety cap can never be supplied.sami666/seo-rank-audit0.2.0 —seo.rank_lookupandseo.rank_and_audit,depth:integer.sami666/google-sheets0.3.2 —google.sheets_bootstrap_oauth,no_open:boolean.
I scanned every installed manifest for flat-shape fields typed integer/boolean/text; those are the three modules that match. I did not execute them (they need credentials I do not hold) — the claim is that validate rejects those values, which the type table above shows directly.
Symptom B, my own module marcofgv/freelancer-com 0.8.4, installed from the marketplace:
cmd_20260825T180805Z_search_projects_..._failed_with_receipt_0006.json—"note": "validation failed: unknown field: query; unknown field: min_budget; unknown field: limit",input_fields: ["limit","min_budget","query"],external_api_touched: false.- v1.5.8's
validateagainst that exact 0.8.4 manifest reproduces the string byte-for-byte and in order:(False, ['unknown field: query', 'unknown field: min_budget', 'unknown field: limit']). - Inversion, same code:
validate(search_projects, {})→(True, []). Andplace_bid(mode: write_requires_approval,risk: high, four required fields) →(True, []). That empty payload is what would reach the approval card. Nothing moves money — but only because my handler happens to open with a 100-character description guard that raises first. That is an accident of my module, not a station property, and I did not runplace_bidto confirm it. - Natural experiment, local times: the command fails at 15:07-15:08; I hand-flatten the manifest at 15:39; the identical payload (
payload_hash sha256:cef4b8f7…, sameidempotency_key) executes cleanly at 16:12 (cmd_20260825T191227Z_..._executed_0012.json,result_status: executed, real API output). Only the schema shape changed.
Publish-time lint does not catch either shape: my 0.8.4 bundle carrying JSON Schema was accepted and installed from the marketplace, and the three modules above are live with integer/boolean. I did not locate a lint rule either way — this is evidence lint did not reject them, not proof no rule exists.
Disclosure: Symptom B is my own listing's bug too. marcofgv/freelancer-com 0.8.4 shipped JSON Schema on all 60 commands, on the strength of that FAQ line. I flattened my installed copy by hand to unblock myself, which is why my later receipts are green. I overwrote that copy, so I can no longer re-read the pre-edit installed manifest; the repro uses the signed 0.8.4 bundle from my source tree instead.
Impact
Symptom A is the wide one. It hits the shape the FAQ recommends and nearly every module uses, and it fails only on the specific inputs typed integer/boolean — so a module looks healthy until an operator supplies that one field, and then the failure reads like the publisher's bug. As a consequence of the validator, an optional field typed integer can never be supplied; a required one would make the command unrunnable. It also puts MCP and the airlock in direct contradiction: tools/list advertises integer, the airlock refuses it.
Symptom B is narrower today, since few modules use JSON Schema — but for those that do, nothing runs, and the firewall inverts: the guarantee in validate_inputs' own docstring, that a malformed payload is "rejected HERE and never surfaced for human approval", does not hold. A zero-input payload reaches the human approval card on a risk: high command. Execution then fails inside the handler rather than doing damage, so I am not claiming a spend path — but a governance check the airlock advertises is silently not running.
Suggested fix
Both symptoms are one function's vocabulary. Smallest change, in approval_airlock.validate:
_TYPES = {"array": list, "string": str, "text": str, "object": dict,
"number": (int, float), "integer": int, "boolean": bool}
py = _TYPES.get(t)
if t is None:
ok = True
elif py is None:
ok = False # unknown type name in the manifest
elif t == "boolean":
ok = isinstance(v, bool)
else:
ok = isinstance(v, py) and not isinstance(v, bool)
This keeps your existing bool-is-not-a-number guard (a True passed to a number field still fails) while giving boolean its own case, and it turns an unrecognised type name into an honest rejection instead of a silent one. That alone fixes Symptom A.
For Symptom B, normalize on the way into the registry so every consumer sees one shape — at routes/modules.py:1374:
cmd = dict(cmd, input_schema=_to_flat_specs(cmd.get("input_schema")))
cmdreg.COMMANDS.append(dict(cmd, wired=True))
where _to_flat_specs is the inverse of the normalizer you already own: if properties or type == "object" is at the top level, return {name: {**prop, "required": name in schema.get("required", [])} for name, prop in schema["properties"].items()}; otherwise return unchanged. That leaves validate untouched and makes "either shape works" true everywhere. If you would rather not touch the loader, do the same detection at approval_airlock.py:199.
Two smaller things in the same path, both optional:
command_registry.py:315,validate_inputs, doesspec.get("required")with noisinstance(spec, dict)guard, so a JSON Schema manifest raisesAttributeError: 'str' object has no attribute 'get'on the"type"key. I confirmed it raises. It is currently unreachable — I grepped v1.5.8 and the function has no callers — so this is latent, not live. Worth the guardapproval_airlock.validatealready carries (its comment dates that fix to 2026-08-13) before anything re-wires it.routes/commands.py:328and:537dooutput, artifact = LOCAL_HANDLERS[cmd_id](inputs or {}, stamp), a bare 2-tuple unpack, while the workflow path calls_flatten_module_result(routes/modules.py:733), which accepts a tuple or a plain dict. A handler returning a dict dies withtoo many values to unpack (expected 2). I hit this onwhoami(cmd_20260825T180804Z_whoami_..._failed_safely_0005.json, noteexecution failed: too many values to unpack (expected 2)).:328is the read-only path and:537the post-approval one; that receipt carries a bound approval, so it went through:537— meaning the failure landed after the operator had already approved. To be accurate about how much it matters: I AST-checked every module installed here and all of them — sami666's four, shweta's zoho-crm — return 2-tuples from every command. Mine was the outlier, so the contract is real and widely followed. The gap is that the FAQ documents handler returns nowhere, and the two dispatch paths disagree on tolerance. Reusing_flatten_module_resultat those two sites, or one line in the FAQ, closes it.
Workaround for other publishers, until this lands
- Type names: use only
string,number,object,arrayininput_schema. Replaceintegerwithnumberandtextwithstring. There is no working spelling forbooleantoday — take it as astring"true"/"false", or asnumber0/1, and coerce inside the handler. - Shape: if you wrote real JSON Schema, flatten it — drop
typeandadditionalProperties, lift each entry ofpropertiesto a top-level key, and set"required": trueon each name listed in therequiredarray:
{"type": "object", "additionalProperties": false,
"properties": {"query": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
"required": ["query"]}
becomes
{"query": {"type": "string", "required": true},
"limit": {"type": "number", "required": false, "default": 10}}
Note both fixes are needed — flattening while keeping "type": "integer" lands you straight on Symptom A.
- Returns: if your handlers return plain dicts, this is enough:
def _tuple_adapter(fn):
def _w(inputs, context=None):
r = fn(inputs, context)
return r if isinstance(r, tuple) else (r, None)
_w.__wrapped__ = fn
return _w
for _n in ["cmd_one", "cmd_two"]: # your command ids
_f = globals().get(_n)
if callable(_f) and not hasattr(_f, "__wrapped__"):
globals()[_n] = _tuple_adapter(_f)